Merge branch 'develop' into ximinez/number-fix-maxrepcusp

This commit is contained in:
Ed Hennis
2026-05-26 16:01:40 -04:00
committed by GitHub
21 changed files with 998 additions and 126 deletions

View File

@@ -2,11 +2,16 @@
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <cstdint>
#include <memory>
#include <optional>
@@ -70,7 +75,8 @@ assetsToSharesWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& assets,
TruncateShares truncate)
TruncateShares truncate,
WaiveUnrealizedLoss waive)
{
XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesWithdraw : non-negative assets");
XRPL_ASSERT(
@@ -80,7 +86,8 @@ assetsToSharesWithdraw(
return std::nullopt; // LCOV_EXCL_LINE
Number assetTotal = vault->at(sfAssetsTotal);
assetTotal -= vault->at(sfLossUnrealized);
if (waive == WaiveUnrealizedLoss::No)
assetTotal -= vault->at(sfLossUnrealized);
STAmount shares{vault->at(sfShareMPTID)};
if (assetTotal == 0)
return shares;
@@ -96,7 +103,8 @@ assetsToSharesWithdraw(
sharesToAssetsWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& shares)
STAmount const& shares,
WaiveUnrealizedLoss waive)
{
XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsWithdraw : non-negative shares");
XRPL_ASSERT(
@@ -106,7 +114,8 @@ sharesToAssetsWithdraw(
return std::nullopt; // LCOV_EXCL_LINE
Number assetTotal = vault->at(sfAssetsTotal);
assetTotal -= vault->at(sfLossUnrealized);
if (waive == WaiveUnrealizedLoss::No)
assetTotal -= vault->at(sfLossUnrealized);
STAmount assets{vault->at(sfAsset)};
if (assetTotal == 0)
return assets;
@@ -115,4 +124,24 @@ sharesToAssetsWithdraw(
return assets;
}
[[nodiscard]] bool
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
{
XRPL_ASSERT(
issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
"xrpl::isSoleShareholder : valid issuance SLE");
std::uint64_t const outstanding = issuance->at(sfOutstandingAmount);
if (outstanding == 0)
return false;
auto const shareMPTID =
makeMptID(issuance->getFieldU32(sfSequence), issuance->getAccountID(sfIssuer));
auto const sleToken = view.read(keylet::mptoken(shareMPTID, account));
if (!sleToken)
return false; // LCOV_EXCL_LINE
return sleToken->getFieldU64(sfMPTAmount) == outstanding;
}
} // namespace xrpl

View File

@@ -4,12 +4,14 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
@@ -26,6 +28,18 @@
namespace xrpl {
static WaiveUnrealizedLoss
shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
{
XRPL_ASSERT(
issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
"xrpl::shouldWaiveWithdrawal : valid issuance sle");
return view.rules().enabled(fixCleanup3_2_0) && isSoleShareholder(view, account, issuance)
? WaiveUnrealizedLoss::Yes
: WaiveUnrealizedLoss::No;
}
NotTEC
VaultWithdraw::preflight(PreflightContext const& ctx)
{
@@ -102,9 +116,14 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
// When the user is the sole shareholder they own both the available and future value.
// We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of
// their shares but keeping future value in the vault.
auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(ctx.view, account, sleIssuance);
try
{
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, amount);
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss);
if (!maybeAssets)
return tefINTERNAL; // LCOV_EXCL_LINE
@@ -182,13 +201,19 @@ VaultWithdraw::doApply()
MPTIssue const share{mptIssuanceID};
STAmount sharesRedeemed = {share};
STAmount assetsWithdrawn;
// When the user is the sole shareholder they own both the available and future value.
// We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their
// shares but keeping future value in the vault.
auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance);
try
{
if (amount.asset() == vaultAsset)
{
// Fixed assets, variable shares.
{
auto const maybeShares = assetsToSharesWithdraw(vault, sleIssuance, amount);
auto const maybeShares = assetsToSharesWithdraw(
vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesRedeemed = *maybeShares;
@@ -196,7 +221,8 @@ VaultWithdraw::doApply()
if (sharesRedeemed == beast::kZero)
return tecPRECISION_LOSS;
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
assetsWithdrawn = *maybeAssets;
@@ -205,7 +231,8 @@ VaultWithdraw::doApply()
{
// Fixed shares, variable assets.
sharesRedeemed = amount;
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
assetsWithdrawn = *maybeAssets;
@@ -238,22 +265,64 @@ VaultWithdraw::doApply()
auto assetsAvailable = vault->at(sfAssetsAvailable);
auto assetsTotal = vault->at(sfAssetsTotal);
[[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
auto const lossUnrealized = vault->at(sfLossUnrealized);
XRPL_ASSERT(
lossUnrealized <= (assetsTotal - assetsAvailable),
"xrpl::VaultWithdraw::doApply : loss and assets do balance");
// The vault must have enough assets on hand. The vault may hold assets
// that it has already pledged. That is why we look at AssetAvailable
// instead of the pseudo-account balance.
// The vault must have enough assets on hand.
if (*assetsAvailable < assetsWithdrawn)
{
JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
return tecINSUFFICIENT_FUNDS;
}
assetsTotal -= assetsWithdrawn;
assetsAvailable -= assetsWithdrawn;
// Post-fixCleanup3_2_0 "final withdrawal" rule:
// a transaction that would burn every outstanding share is only permitted when the vault is in
// a clean state — no outstanding receivables and no unrealized loss. Otherwise the resulting
// (shares == 0, assetsTotal > 0) state would violate the zero-sized-vault invariant.
//
// When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
// the helper result should already equal that value, and any mismatch is a rounding artifact
// worth logging.
bool const isFinalWithdrawal =
sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
{
// Unreachable: a final withdrawal with lossUnrealized > 0 has
// assetsWithdrawn == assetsTotal > assetsAvailable, which the
// insufficient-funds guard above already rejected.
if (*lossUnrealized != beast::kZero)
{
// LCOV_EXCL_START
UNREACHABLE(
"xrpl::VaultWithdraw::doApply : final withdrawal with non-zero unrealized loss");
JLOG(j_.fatal())
<< "VaultWithdraw: " //
"Cannot burn all outstanding shares while unrealized loss is non-zero";
return tefINTERNAL;
// LCOV_EXCL_END
}
STAmount const allAvailable{vaultAsset, *assetsAvailable};
if (assetsWithdrawn != allAvailable)
{
JLOG(j_.error()) //
<< "VaultWithdraw: final withdrawal share-value mismatch;"
<< " computed=" << assetsWithdrawn.getText()
<< " assetsAvailable=" << allAvailable.getText();
}
assetsWithdrawn = allAvailable;
// Do not let dust accumulate in the Vault.
assetsTotal = 0;
assetsAvailable = 0;
}
else
{
assetsTotal -= assetsWithdrawn;
assetsAvailable -= assetsWithdrawn;
}
view().update(vault);
auto const& vaultAccount = vault->at(sfAccount);

View File

@@ -6457,6 +6457,604 @@ class Vault_test : public beast::unit_test::Suite
runTest(amendments);
}
// -----------------------------------------------------------------------
// Helpers and tests: sole-shareholder / stuck-depositor (XLS-0065 +
// fixCleanup3_2_0). The vault-level withdraw behavior is tested here;
// the loan-protocol setup is incidental.
// -----------------------------------------------------------------------
FeatureBitset const all_{test::jtx::testableAmendments()};
std::string const iouCurrency_{"IOU"};
// design doc:
// AssetsAvailable ≈ 3,333.50
// AssetsTotal ≈ 6,666.50 (3,333.50 cash + 3,333 receivable)
// LossUnrealized = 3,333
// OutstandingShares = sharesLender (5e9 at IOU scale 1e6)
struct StuckDepositorFixture
{
test::jtx::Account issuer{"issuer"};
test::jtx::Account lender{"lender"};
test::jtx::Account bob{"bob"};
test::jtx::Account borrower{"borrower"};
std::optional<PrettyAsset> asset;
std::optional<Keylet> vaultKeylet;
uint256 brokerID;
std::optional<Keylet> loanKeylet;
MPTID shareAsset;
std::uint64_t sharesLender = 0;
};
static constexpr std::int64_t kStuckFunding = 1'000'000;
static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
static constexpr std::int64_t kStuckDeposit = 5'000;
static constexpr std::int64_t kStuckPrincipal = 3'333;
static constexpr std::uint32_t kStuckPayInterval = 600;
static constexpr std::uint32_t kStuckPayTotal = 2;
[[nodiscard]] StuckDepositorFixture
setupStuckDepositor(test::jtx::Env& env)
{
using namespace test::jtx;
StuckDepositorFixture f;
f.asset = f.issuer[iouCurrency_];
env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
env.close();
env(trust(f.lender, (*f.asset)(10'000'000)));
env(trust(f.bob, (*f.asset)(10'000'000)));
env(trust(f.borrower, (*f.asset)(10'000'000)));
env.close();
env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
env.close();
// Vault: Lender creates and seeds it; Bob matches the deposit for a
// clean 50/50 split.
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
env(createTx);
env.close();
if (!BEAST_EXPECT(env.le(vaultKeylet)))
return f;
f.vaultKeylet = vaultKeylet;
env(v.deposit({
.depositor = f.lender,
.id = vaultKeylet.key,
.amount = (*f.asset)(kStuckDeposit),
}),
Ter(tesSUCCESS));
env(v.deposit({
.depositor = f.bob,
.id = vaultKeylet.key,
.amount = (*f.asset)(kStuckDeposit),
}),
Ter(tesSUCCESS));
env.close();
// Loan broker: no cover, no management fee, debt cap 10x principal.
f.brokerID = keylet::loanbroker(f.lender.id(), env.seq(f.lender)).key;
{
using namespace loanBroker;
env(set(f.lender, vaultKeylet.key),
kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
env.close();
}
// Loan: 3,333 USD principal, impaired immediately.
auto const sleBroker = env.le(keylet::loanbroker(f.brokerID));
if (!BEAST_EXPECT(sleBroker))
return f;
f.loanKeylet = keylet::loan(f.brokerID, sleBroker->at(sfLoanSequence));
{
using namespace loan;
env(set(f.borrower, f.brokerID, kStuckPrincipal),
Sig(sfCounterpartySignature, f.lender),
kPaymentTotal(kStuckPayTotal),
kPaymentInterval(kStuckPayInterval),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
}
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return f;
BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
f.shareAsset = vaultSle->at(sfShareMPTID);
auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
if (!BEAST_EXPECT(tokenBob))
return f;
std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
// Bob (non-sole) exits at the discounted rate. Always succeeds.
STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
env(v.withdraw({
.depositor = f.bob,
.id = vaultKeylet.key,
.amount = bobShareAmt,
}),
Ter(tesSUCCESS));
env.close();
auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
if (!BEAST_EXPECT(tokenLender))
return f;
f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
auto const sleIssuance = env.le(keylet::mptIssuance(f.shareAsset));
if (!BEAST_EXPECT(sleIssuance))
return f;
BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
auto const vaultAfterBob = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultAfterBob))
return f;
// After Bob's exit: loss is unchanged (3,333 receivable), and the
// gap between assetsTotal and assetsAvailable equals exactly that
// receivable.
BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
BEAST_EXPECT(
vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
vaultAfterBob->at(sfLossUnrealized));
return f;
}
// Reproduces the worked example from the XLS-0065 design doc. The sole
// remaining shareholder asks (via fixed-asset input) for the vault's
// entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
// invariant violation. Post-fix the full-price exchange rate burns
// only a portion of the shares, the depositor receives all of
// AssetsAvailable, and the residual shares remain backed by the
// impaired-loan receivable.
void
testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder exits via "
"fixed-asset amount with impaired loan"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
PrettyAsset const& asset = *f.asset;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
// The requested amount differs between feature regimes because
// the two regimes are testing different behaviors:
//
// - Pre-fix: request the full AssetsAvailable (3,333.50). Under
// the discounted formula this would burn every outstanding
// share, hitting the zero-sized-vault invariant. The
// transaction is rejected with tecINVARIANT_FAILED — the
// stuck-depositor bug.
//
// - Post-fix: request a strictly smaller amount (1,000 USD).
// The full-price formula burns only ~30% of the outstanding
// shares; the vault retains the rest, backed by the impaired
// receivable. Requesting *exactly* AssetsAvailable post-fix
// would currently fail with tecINSUFFICIENT_FUNDS due to the
// round-to-nearest used by assetsToSharesWithdraw (the
// recomputed payout can overshoot the request by a few ULPs).
// The "force payout to AssetsAvailable" branch in doApply
// only triggers when every share is burned, which is covered
// by the loan-repayment test.
STAmount const requestAssets =
withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = requestAssets,
}),
Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
env.close();
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
Number const totalAfter = vaultAfter->at(sfAssetsTotal);
Number const lossAfter = vaultAfter->at(sfLossUnrealized);
if (!withFix)
{
// Pre-fix: rejected — vault state unchanged.
BEAST_EXPECT(sharesAfter == f.sharesLender);
BEAST_EXPECT(availableAfter == availableBefore);
BEAST_EXPECT(totalAfter == totalBefore);
BEAST_EXPECT(lossAfter == lossBefore);
return;
}
// Post-fix exact-value derivation (fixture: sharesLender=5e9,
// totalBefore=6666.5, request=1000):
// sharesRedeemed = round(sharesLender * request / totalBefore)
// = round(750,018,750.469) = 750,018,750
// received = totalBefore * sharesRedeemed / sharesLender
// = 999.999999375 (slightly under 1,000 due to
// integer-share rounding)
constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
Number const expectedReceived =
totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
// LossUnrealized is unchanged: the loan-protocol side is untouched.
BEAST_EXPECT(lossAfter == lossBefore);
// The entire (total - available) gap is the impaired receivable,
// i.e. equal to lossUnrealized.
BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const received{lenderBalanceAfter - lenderBalanceBefore};
BEAST_EXPECT(received == expectedReceived);
// Conservation: assets removed from the vault equal what the
// depositor received.
BEAST_EXPECT(totalBefore - totalAfter == received);
BEAST_EXPECT(availableBefore - availableAfter == received);
}
// Sole shareholder attempts to burn ALL outstanding shares via
// fixed-shares input while the vault still holds an impaired
// receivable. Pre-fix this fails with the zero-sized-vault invariant
// violation. Post-fix the full-price rate causes assetsWithdrawn to
// equal assetsTotal, which exceeds assetsAvailable, so the transaction
// is rejected with tecINSUFFICIENT_FUNDS.
void
testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder full-shares "
"burn is rejected while loss outstanding"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
// Fixed-shares input: ask for ALL outstanding shares.
STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = shareAmt,
}),
Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
env.close();
// Either way the transaction was rejected; vault state unchanged.
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
}
// Post-fix end-to-end resolution: after the sole-shareholder partial
// exit, the loan is repaid in full. With unrealized loss cleared and
// all assets back as cash, the depositor can burn all remaining
// shares and fully exit the vault. The final withdrawal hits the
// "force payout to assetsAvailable" branch in doApply.
void
testWithdrawSoleShareholderLoanRepaymentExit()
{
using namespace test::jtx;
using namespace loan;
testcase(
"Vault withdraw: sole shareholder fully exits after impaired "
"loan is repaid (fixCleanup3_2_0)");
Env env(*this, all_ | fixCleanup3_2_0);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
Keylet const& loanKey = *f.loanKeylet;
PrettyAsset const& asset = *f.asset;
Vault const v{env};
// Sole-shareholder partial exit (see comment in
// testWithdrawSoleShareholderFixedAssetExit for why we request
// less than full AssetsAvailable).
{
STAmount const requestAssets = asset(1000).value();
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = requestAssets,
}),
Ter(tesSUCCESS));
env.close();
}
// Confirm the "dormant-but-alive" state from the design doc. The
// partial exit burned exactly 750,018,750 shares (see derivation
// in testWithdrawSoleShareholderFixedAssetExit).
auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
if (!BEAST_EXPECT(tokenAfterExit))
return;
std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
// Borrower repays the loan in full (pays more than the outstanding
// total; the loan transactor caps the receivable).
env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
env.close();
auto const vaultAfterRepay = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfterRepay))
return;
// Repayment converts the 3,333 receivable back to cash; assetsTotal
// is unchanged but assetsAvailable jumps by exactly the same amount,
// and lossUnrealized clears to zero.
BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
// Burn all remaining shares — the clean-state preconditions of
// the "final withdrawal" guard are now satisfied.
STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = allShares,
}),
Ter(tesSUCCESS));
env.close();
auto const vaultFinal = env.le(vaultKey);
if (!BEAST_EXPECT(vaultFinal))
return;
auto const issuanceFinal = env.le(keylet::mptIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceFinal))
return;
// Zero-sized vault invariant satisfied: 0 shares, 0 assets.
BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
// The final payout equals exactly the AssetsAvailable that
// existed before the call (the "force payout" branch).
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
BEAST_EXPECT(finalReceived == availableBeforeFinal);
}
// Clean-state regression: with no impaired loan, a sole shareholder
// burning all their shares fully empties the vault under both the
// pre-fix and post-fix code paths. Confirms the new logic doesn't
// break the existing happy-path close-out.
void
testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder clean-state "
"close-out unchanged"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
env.fund(XRP(kStuckFunding), issuer, lender);
env.close();
PrettyAsset const asset = issuer[iouCurrency_];
env(trust(lender, asset(10'000'000)));
env.close();
env(pay(issuer, lender, asset(kStuckDepositorIOU)));
env.close();
// Sole shareholder of a clean vault — no loan broker needed.
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
env(createTx);
env.close();
env(v.deposit({
.depositor = lender,
.id = vaultKeylet.key,
.amount = asset(kStuckDeposit),
}),
Ter(tesSUCCESS));
env.close();
auto const vaultBefore = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultBefore))
return;
auto const shareAsset = vaultBefore->at(sfShareMPTID);
auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
if (!BEAST_EXPECT(tokenLender))
return;
std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
// Sole shareholder, no loans, no loss. Burn everything.
STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
env(v.withdraw({
.depositor = lender,
.id = vaultKeylet.key,
.amount = allShares,
}),
Ter(tesSUCCESS));
env.close();
auto const vaultFinal = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultFinal))
return;
auto const issuanceFinal = env.le(keylet::mptIssuance(shareAsset));
if (!BEAST_EXPECT(issuanceFinal))
return;
BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
// (Pre-fix path takes the regular code path; post-fix path enters
// the new final-withdrawal guard, which forces payout to exactly
// assetsAvailable. Either way the result is identical for a clean
// vault.)
(void)withFix;
}
// Sole shareholder in an impaired vault redeems a *partial* count of
// shares via fixed-shares input. Pre-fix the discounted formula is
// used; post-fix the full-price formula is used (waiveUnrealizedLoss
// = Yes). The relative payout therefore differs, and post-fix the
// depositor recovers proportionally more of the residual cash for
// the shares burned. In both cases the vault is left in a valid
// (non-empty) state.
void
testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
{
using namespace test::jtx;
testcase(
"Vault withdraw: sole-shareholder partial fixed-shares uses "
"full-price rate (fixCleanup3_2_0)");
Env env(*this, all_ | fixCleanup3_2_0);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
PrettyAsset const& asset = *f.asset;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
// Burn exactly half of the outstanding shares.
std::uint64_t const halfShares = f.sharesLender / 2;
STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = halfAmt,
}),
Ter(tesSUCCESS));
env.close();
// Expected payout under the full-price formula:
// assets = totalBefore * halfShares / sharesLender
// which (with halfShares == sharesLender/2) is roughly
// totalBefore / 2.
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const received{lenderBalanceAfter - lenderBalanceBefore};
Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
BEAST_EXPECT(received == expected);
// The full-price payout exceeds the discounted formula by exactly
// lossBefore * halfShares / sharesLender — that's the whole point
// of the waive.
Number const discounted =
(totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
BEAST_EXPECT(received - discounted == expectedDelta);
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
// Vault remains valid: half the shares remain, lossUnrealized
// is untouched, and the entire (total - available) gap is still
// the impaired receivable.
BEAST_EXPECT(
issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
vaultAfter->at(sfLossUnrealized));
// Conservation: vault delta matches the depositor's gain.
BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
}
// Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
// sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
// same max() for the vault pseudo-account RippleState. When
@@ -7449,6 +8047,16 @@ public:
testAssetsMaximum();
testBug6LimitBypassWithShares();
testRemoveEmptyHoldingLockedAmount();
testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderFixedAssetExit(all_);
testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderFullSharesRejected(all_);
testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderCleanVaultUnaffected(all_);
testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
testWithdrawSoleShareholderLoanRepaymentExit();
testReferenceHolding();
testHoldingDeletionBlocked();
}