mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-03 08:46:46 +00:00
Merge branch 'develop' into ximinez/number-fix-maxrepcusp
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user