mirror of
https://github.com/XRPLF/rippled.git
synced 2026-04-09 13:32:23 +00:00
Compare commits
39 Commits
ximinez/re
...
a1q123456/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47ca5654d | ||
|
|
947f56e677 | ||
|
|
f95f87d673 | ||
|
|
c53ea3c11d | ||
|
|
0bc4be2b9c | ||
|
|
bb0a09ae21 | ||
|
|
e74a24bced | ||
|
|
4c665f1678 | ||
|
|
d94232007f | ||
|
|
df8bfbe5af | ||
|
|
347d1a19ef | ||
|
|
6466e94bb8 | ||
|
|
d65fab27a1 | ||
|
|
b5d25c5ab1 | ||
|
|
7222150095 | ||
|
|
afca681a86 | ||
|
|
668e677dff | ||
|
|
3101619029 | ||
|
|
b42fbeaaeb | ||
|
|
a67da5c2ed | ||
|
|
d2f23b2f5b | ||
|
|
4067e5025f | ||
|
|
ed4330a7d6 | ||
|
|
feba605998 | ||
|
|
b322097529 | ||
|
|
e159d27373 | ||
|
|
ba53026006 | ||
|
|
34773080df | ||
|
|
3c3bd75991 | ||
|
|
7459fe454d | ||
|
|
106bf48725 | ||
|
|
74c968d4e3 | ||
|
|
167147281c | ||
|
|
ba60306610 | ||
|
|
6674500896 | ||
|
|
c5d7ebe93d | ||
|
|
d0b5ca9dab | ||
|
|
5e51893e9b | ||
|
|
3422c11d02 |
@@ -42,8 +42,8 @@ private:
|
||||
public:
|
||||
using value_type = STAmount;
|
||||
|
||||
static int const cMinOffset = -96;
|
||||
static int const cMaxOffset = 80;
|
||||
static int constexpr cMinOffset = -96;
|
||||
static int constexpr cMaxOffset = 80;
|
||||
|
||||
// Maximum native value supported by the code
|
||||
constexpr static std::uint64_t cMinValue = 1'000'000'000'000'000ull;
|
||||
@@ -739,6 +739,21 @@ canAdd(STAmount const& amt1, STAmount const& amt2);
|
||||
bool
|
||||
canSubtract(STAmount const& amt1, STAmount const& amt2);
|
||||
|
||||
/** Get the scale of a Number for a given asset.
|
||||
*
|
||||
* "scale" is similar to "exponent", but from the perspective of STAmount, which has different rules
|
||||
* and mantissa ranges for determining the exponent than Number.
|
||||
*
|
||||
* @param number The Number to get the scale of.
|
||||
* @param asset The asset to use for determining the scale.
|
||||
* @return The scale of this Number for the given asset.
|
||||
*/
|
||||
inline int
|
||||
scale(Number const& number, Asset const& asset)
|
||||
{
|
||||
return STAmount{asset, number}.exponent();
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// Keep it sorted in reverse chronological order.
|
||||
|
||||
XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(LendingProtocolV1_1, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (ExpiredNFTokenOfferRemoval, Supported::yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (BatchInnerSigs, Supported::no, VoteBehavior::DefaultNo)
|
||||
|
||||
@@ -514,6 +514,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
|
||||
{sfDebtMaximum, soeDEFAULT},
|
||||
{sfCoverAvailable, soeDEFAULT},
|
||||
{sfCoverRateMinimum, soeDEFAULT},
|
||||
// Deprecated by featureLendingProtocolV1_1
|
||||
{sfCoverRateLiquidation, soeDEFAULT},
|
||||
}))
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ TYPED_SFIELD(sfPaymentRemaining, UINT32, 59)
|
||||
TYPED_SFIELD(sfPaymentTotal, UINT32, 60)
|
||||
TYPED_SFIELD(sfLoanSequence, UINT32, 61)
|
||||
TYPED_SFIELD(sfCoverRateMinimum, UINT32, 62) // 1/10 basis points (bips)
|
||||
// Deprecated by featureLendingProtocolV1_1
|
||||
TYPED_SFIELD(sfCoverRateLiquidation, UINT32, 63) // 1/10 basis points (bips)
|
||||
TYPED_SFIELD(sfOverpaymentFee, UINT32, 64) // 1/10 basis points (bips)
|
||||
TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bips)
|
||||
|
||||
@@ -886,6 +886,7 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete,
|
||||
mustDeleteAcct | destroyMPTIssuance | mustModifyVault,
|
||||
({
|
||||
{sfVaultID, soeREQUIRED},
|
||||
{sfMemoData, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
/** This transaction trades assets for shares with a vault. */
|
||||
@@ -959,6 +960,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet,
|
||||
{sfManagementFeeRate, soeOPTIONAL},
|
||||
{sfDebtMaximum, soeOPTIONAL},
|
||||
{sfCoverRateMinimum, soeOPTIONAL},
|
||||
// Deprecated by featureLendingProtocolV1_1
|
||||
{sfCoverRateLiquidation, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
|
||||
@@ -57,6 +57,32 @@ public:
|
||||
{
|
||||
return this->tx_->at(sfVaultID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfMemoData (soeOPTIONAL)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_VL::type::value_type>
|
||||
getMemoData() const
|
||||
{
|
||||
if (hasMemoData())
|
||||
{
|
||||
return this->tx_->at(sfMemoData);
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfMemoData is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasMemoData() const
|
||||
{
|
||||
return this->tx_->isFieldPresent(sfMemoData);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -112,6 +138,17 @@ public:
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfMemoData (soeOPTIONAL)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultDeleteBuilder&
|
||||
setMemoData(std::decay_t<typename SF_VL::type::value_type> const& value)
|
||||
{
|
||||
object_[sfMemoData] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build and return the VaultDelete wrapper.
|
||||
* @param publicKey The public key for signing.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -60,11 +61,19 @@ class ValidVault
|
||||
Shares static make(SLE const&);
|
||||
};
|
||||
|
||||
public:
|
||||
struct DeltaInfo final
|
||||
{
|
||||
Number delta = numZero;
|
||||
std::optional<int> scale;
|
||||
};
|
||||
|
||||
private:
|
||||
std::vector<Vault> afterVault_ = {};
|
||||
std::vector<Shares> afterMPTs_ = {};
|
||||
std::vector<Vault> beforeVault_ = {};
|
||||
std::vector<Shares> beforeMPTs_ = {};
|
||||
std::unordered_map<uint256, Number> deltas_ = {};
|
||||
std::unordered_map<uint256, DeltaInfo> deltas_ = {};
|
||||
|
||||
public:
|
||||
void
|
||||
@@ -72,6 +81,10 @@ public:
|
||||
|
||||
bool
|
||||
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
|
||||
|
||||
// Compute the coarsest scale required to represent all numbers
|
||||
[[nodiscard]] static std::int32_t
|
||||
computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers);
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -171,7 +171,7 @@ getAssetsTotalScale(SLE::const_ref vaultSle)
|
||||
{
|
||||
if (!vaultSle)
|
||||
return Number::minExponent - 1; // LCOV_EXCL_LINE
|
||||
return STAmount{vaultSle->at(sfAsset), vaultSle->at(sfAssetsTotal)}.exponent();
|
||||
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
|
||||
}
|
||||
|
||||
TER
|
||||
@@ -219,6 +219,49 @@ computeFullPaymentInterest(
|
||||
std::uint32_t startDate,
|
||||
TenthBips32 closeInterestRate);
|
||||
|
||||
/** Whether to use the proportional (new) default cover formula.
|
||||
*
|
||||
* Returns true when featureLendingProtocolV1_1 is enabled AND the broker
|
||||
* does not carry the deprecated sfCoverRateLiquidation field.
|
||||
*/
|
||||
inline bool
|
||||
useProportionalDefaultCover(Rules const& rules, std::shared_ptr<SLE const> const& brokerSle)
|
||||
{
|
||||
return rules.enabled(featureLendingProtocolV1_1) &&
|
||||
!brokerSle->isFieldPresent(sfCoverRateLiquidation);
|
||||
}
|
||||
|
||||
/** Compute the amount of First-Loss Capital seized to cover a defaulted loan.
|
||||
*
|
||||
* Selects between the old (global) and new (proportional) formula based on
|
||||
* whether featureLendingProtocolV1_1 is enabled and whether the broker still
|
||||
* carries the deprecated sfCoverRateLiquidation value.
|
||||
*
|
||||
* @param useProportionalFormula true when featureLendingProtocolV1_1 is
|
||||
* enabled AND the broker has no
|
||||
* sfCoverRateLiquidation.
|
||||
* @param coverRateLiquidation The broker's CoverRateLiquidation in 1/10
|
||||
* bips. Only used by the old formula; ignored
|
||||
* when \p useProportionalFormula is true.
|
||||
* @param coverAvailable The broker's current CoverAvailable.
|
||||
* @param vaultAsset The Vault's asset type (for rounding).
|
||||
* @param totalDefaultAmount The loan's default amount (owed to the vault).
|
||||
* @param brokerDebtTotal The broker's total debt before this default.
|
||||
* @param coverRateMinimum The broker's CoverRateMinimum in 1/10 bips.
|
||||
* @param loanScale The loan's rounding scale.
|
||||
* @return The amount of cover seized, capped at \p coverAvailable.
|
||||
*/
|
||||
Number
|
||||
computeDefaultCovered(
|
||||
bool useProportionalFormula,
|
||||
std::uint32_t coverRateLiquidation,
|
||||
Number const& coverAvailable,
|
||||
Asset const& vaultAsset,
|
||||
Number const& totalDefaultAmount,
|
||||
Number const& brokerDebtTotal,
|
||||
TenthBips32 coverRateMinimum,
|
||||
std::int32_t loanScale);
|
||||
|
||||
namespace detail {
|
||||
// These classes and functions should only be accessed by LendingHelper
|
||||
// functions and unit tests
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
|
||||
@@ -61,10 +62,12 @@ ValidVault::visitEntry(
|
||||
"xrpl::ValidVault::visitEntry : some object is available");
|
||||
|
||||
// Number balanceDelta will capture the difference (delta) between "before"
|
||||
// state (zero if created) and "after" state (zero if destroyed), so the
|
||||
// invariants can validate that the change in account balances matches the
|
||||
// change in vault balances, stored to deltas_ at the end of this function.
|
||||
Number balanceDelta{};
|
||||
// state (zero if created) and "after" state (zero if destroyed), and
|
||||
// preserves value scale (exponent) to round values to the same scale during
|
||||
// validation. It is used to validate that the change in account
|
||||
// balances matches the change in vault balances, stored to deltas_ at the
|
||||
// end of this function.
|
||||
DeltaInfo balanceDelta{numZero, std::nullopt};
|
||||
|
||||
std::int8_t sign = 0;
|
||||
if (before)
|
||||
@@ -78,18 +81,34 @@ ValidVault::visitEntry(
|
||||
// At this moment we have no way of telling if this object holds
|
||||
// vault shares or something else. Save it for finalize.
|
||||
beforeMPTs_.push_back(Shares::make(*before));
|
||||
balanceDelta = static_cast<std::int64_t>(before->getFieldU64(sfOutstandingAmount));
|
||||
balanceDelta.delta =
|
||||
static_cast<std::int64_t>(before->getFieldU64(sfOutstandingAmount));
|
||||
// MPTs are ints, so the scale is always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = 1;
|
||||
break;
|
||||
case ltMPTOKEN:
|
||||
balanceDelta = static_cast<std::int64_t>(before->getFieldU64(sfMPTAmount));
|
||||
balanceDelta.delta = static_cast<std::int64_t>(before->getFieldU64(sfMPTAmount));
|
||||
// MPTs are ints, so the scale is always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = -1;
|
||||
break;
|
||||
case ltACCOUNT_ROOT:
|
||||
case ltRIPPLE_STATE:
|
||||
balanceDelta = before->getFieldAmount(sfBalance);
|
||||
balanceDelta.delta = before->getFieldAmount(sfBalance);
|
||||
// Account balance is XRP, which is an int, so the scale is
|
||||
// always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = -1;
|
||||
break;
|
||||
case ltRIPPLE_STATE: {
|
||||
auto const amount = before->getFieldAmount(sfBalance);
|
||||
balanceDelta.delta = amount;
|
||||
// Trust Line balances are STAmounts, so we can use the exponent
|
||||
// directly to get the scale.
|
||||
balanceDelta.scale = amount.exponent();
|
||||
sign = -1;
|
||||
break;
|
||||
}
|
||||
default:;
|
||||
}
|
||||
}
|
||||
@@ -105,19 +124,36 @@ ValidVault::visitEntry(
|
||||
// At this moment we have no way of telling if this object holds
|
||||
// vault shares or something else. Save it for finalize.
|
||||
afterMPTs_.push_back(Shares::make(*after));
|
||||
balanceDelta -=
|
||||
balanceDelta.delta -=
|
||||
Number(static_cast<std::int64_t>(after->getFieldU64(sfOutstandingAmount)));
|
||||
// MPTs are ints, so the scale is always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = 1;
|
||||
break;
|
||||
case ltMPTOKEN:
|
||||
balanceDelta -= Number(static_cast<std::int64_t>(after->getFieldU64(sfMPTAmount)));
|
||||
balanceDelta.delta -=
|
||||
Number(static_cast<std::int64_t>(after->getFieldU64(sfMPTAmount)));
|
||||
// MPTs are ints, so the scale is always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = -1;
|
||||
break;
|
||||
case ltACCOUNT_ROOT:
|
||||
case ltRIPPLE_STATE:
|
||||
balanceDelta -= Number(after->getFieldAmount(sfBalance));
|
||||
balanceDelta.delta -= Number(after->getFieldAmount(sfBalance));
|
||||
// Account balance is XRP, which is an int, so the scale is
|
||||
// always 0.
|
||||
balanceDelta.scale = 0;
|
||||
sign = -1;
|
||||
break;
|
||||
case ltRIPPLE_STATE: {
|
||||
auto const amount = after->getFieldAmount(sfBalance);
|
||||
balanceDelta.delta -= Number(amount);
|
||||
// Trust Line balances are STAmounts, so we can use the exponent
|
||||
// directly to get the scale.
|
||||
if (amount.exponent() > balanceDelta.scale)
|
||||
balanceDelta.scale = amount.exponent();
|
||||
sign = -1;
|
||||
break;
|
||||
}
|
||||
default:;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +165,11 @@ ValidVault::visitEntry(
|
||||
// transferred to the account. We intentionally do not compare balanceDelta
|
||||
// against zero, to avoid missing such updates.
|
||||
if (sign != 0)
|
||||
deltas_[key] = balanceDelta * sign;
|
||||
{
|
||||
XRPL_ASSERT_PARTS(balanceDelta.scale, "xrpl::ValidVault::visitEntry", "scale initialized");
|
||||
balanceDelta.delta *= sign;
|
||||
deltas_[key] = balanceDelta;
|
||||
}
|
||||
}
|
||||
|
||||
bool
|
||||
@@ -391,13 +431,13 @@ ValidVault::finalize(
|
||||
}
|
||||
|
||||
auto const& vaultAsset = afterVault.asset;
|
||||
auto const deltaAssets = [&](AccountID const& id) -> std::optional<Number> {
|
||||
auto const deltaAssets = [&](AccountID const& id) -> std::optional<DeltaInfo> {
|
||||
auto const get = //
|
||||
[&](auto const& it, std::int8_t sign = 1) -> std::optional<Number> {
|
||||
[&](auto const& it, std::int8_t sign = 1) -> std::optional<DeltaInfo> {
|
||||
if (it == deltas_.end())
|
||||
return std::nullopt;
|
||||
|
||||
return it->second * sign;
|
||||
return DeltaInfo{it->second.delta * sign, it->second.scale};
|
||||
};
|
||||
|
||||
return std::visit(
|
||||
@@ -416,7 +456,7 @@ ValidVault::finalize(
|
||||
},
|
||||
vaultAsset.value());
|
||||
};
|
||||
auto const deltaAssetsTxAccount = [&]() -> std::optional<Number> {
|
||||
auto const deltaAssetsTxAccount = [&]() -> std::optional<DeltaInfo> {
|
||||
auto ret = deltaAssets(tx[sfAccount]);
|
||||
// Nothing returned or not XRP transaction
|
||||
if (!ret.has_value() || !vaultAsset.native())
|
||||
@@ -427,20 +467,20 @@ ValidVault::finalize(
|
||||
delegate.has_value() && *delegate != tx[sfAccount])
|
||||
return ret;
|
||||
|
||||
*ret += fee.drops();
|
||||
if (*ret == zero)
|
||||
ret->delta += fee.drops();
|
||||
if (ret->delta == zero)
|
||||
return std::nullopt;
|
||||
|
||||
return ret;
|
||||
};
|
||||
auto const deltaShares = [&](AccountID const& id) -> std::optional<Number> {
|
||||
auto const deltaShares = [&](AccountID const& id) -> std::optional<DeltaInfo> {
|
||||
auto const it = [&]() {
|
||||
if (id == afterVault.pseudoId)
|
||||
return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
|
||||
return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
|
||||
}();
|
||||
|
||||
return it != deltas_.end() ? std::optional<Number>(it->second) : std::nullopt;
|
||||
return it != deltas_.end() ? std::optional<DeltaInfo>(it->second) : std::nullopt;
|
||||
};
|
||||
|
||||
auto const vaultHoldsNoAssets = [&](Vault const& vault) {
|
||||
@@ -567,16 +607,38 @@ ValidVault::finalize(
|
||||
!beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault");
|
||||
auto const& beforeVault = beforeVault_[0];
|
||||
|
||||
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
|
||||
if (!vaultDeltaAssets)
|
||||
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
if (!maybeVaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change vault balance";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*vaultDeltaAssets > tx[sfAmount])
|
||||
// Get the coarsest scale to round calculations to
|
||||
DeltaInfo totalDelta{
|
||||
afterVault.assetsTotal - beforeVault.assetsTotal,
|
||||
std::max(
|
||||
scale(afterVault.assetsTotal, vaultAsset),
|
||||
scale(beforeVault.assetsTotal, vaultAsset))};
|
||||
DeltaInfo availableDelta{
|
||||
afterVault.assetsAvailable - beforeVault.assetsAvailable,
|
||||
std::max(
|
||||
scale(afterVault.assetsAvailable, vaultAsset),
|
||||
scale(beforeVault.assetsAvailable, vaultAsset))};
|
||||
auto const minScale = computeMinScale(
|
||||
vaultAsset,
|
||||
{
|
||||
*maybeVaultDeltaAssets,
|
||||
totalDelta,
|
||||
availableDelta,
|
||||
});
|
||||
|
||||
auto const vaultDeltaAssets =
|
||||
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
|
||||
auto const txAmount = roundToAsset(vaultAsset, tx[sfAmount], minScale);
|
||||
|
||||
if (vaultDeltaAssets > txAmount)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must not change vault "
|
||||
@@ -584,7 +646,7 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (*vaultDeltaAssets <= zero)
|
||||
if (vaultDeltaAssets <= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must increase vault balance";
|
||||
@@ -601,16 +663,23 @@ ValidVault::finalize(
|
||||
|
||||
if (!issuerDeposit)
|
||||
{
|
||||
auto const accountDeltaAssets = deltaAssetsTxAccount();
|
||||
if (!accountDeltaAssets)
|
||||
auto const maybeAccDeltaAssets = deltaAssetsTxAccount();
|
||||
if (!maybeAccDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change depositor "
|
||||
"balance";
|
||||
return false;
|
||||
}
|
||||
auto const localMinScale =
|
||||
std::max(minScale, computeMinScale(vaultAsset, {*maybeAccDeltaAssets}));
|
||||
|
||||
if (*accountDeltaAssets >= zero)
|
||||
auto const accountDeltaAssets =
|
||||
roundToAsset(vaultAsset, maybeAccDeltaAssets->delta, localMinScale);
|
||||
auto const localVaultDeltaAssets =
|
||||
roundToAsset(vaultAsset, vaultDeltaAssets, localMinScale);
|
||||
|
||||
if (accountDeltaAssets >= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must decrease depositor "
|
||||
@@ -618,7 +687,7 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (*accountDeltaAssets * -1 != *vaultDeltaAssets)
|
||||
if (localVaultDeltaAssets * -1 != accountDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change vault and "
|
||||
@@ -636,16 +705,17 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
|
||||
if (!accountDeltaShares)
|
||||
auto const maybeAccDeltaShares = deltaShares(tx[sfAccount]);
|
||||
if (!maybeAccDeltaShares)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change depositor "
|
||||
"shares";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*accountDeltaShares <= zero)
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const& accountDeltaShares = *maybeAccDeltaShares;
|
||||
if (accountDeltaShares.delta <= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must increase depositor "
|
||||
@@ -653,15 +723,17 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
|
||||
if (!vaultDeltaShares || *vaultDeltaShares == zero)
|
||||
auto const maybeVaultDeltaShares = deltaShares(afterVault.pseudoId);
|
||||
if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change vault shares";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*vaultDeltaShares * -1 != *accountDeltaShares)
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const& vaultDeltaShares = *maybeVaultDeltaShares;
|
||||
if (vaultDeltaShares.delta * -1 != accountDeltaShares.delta)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: deposit must change depositor and "
|
||||
@@ -669,13 +741,18 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
|
||||
auto const assetTotalDelta = roundToAsset(
|
||||
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
|
||||
if (assetTotalDelta != vaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
|
||||
"outstanding must add up";
|
||||
result = false;
|
||||
}
|
||||
if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable)
|
||||
|
||||
auto const assetAvailableDelta = roundToAsset(
|
||||
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
|
||||
if (assetAvailableDelta != vaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
|
||||
"available must add up";
|
||||
@@ -693,16 +770,33 @@ ValidVault::finalize(
|
||||
"vault");
|
||||
auto const& beforeVault = beforeVault_[0];
|
||||
|
||||
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
|
||||
if (!vaultDeltaAssets)
|
||||
if (!maybeVaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
|
||||
"change vault balance";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*vaultDeltaAssets >= zero)
|
||||
// Get the most coarse scale to round calculations to
|
||||
auto const totalDelta = DeltaInfo{
|
||||
afterVault.assetsTotal - beforeVault.assetsTotal,
|
||||
std::max(
|
||||
scale(afterVault.assetsTotal, vaultAsset),
|
||||
scale(beforeVault.assetsTotal, vaultAsset))};
|
||||
auto const availableDelta = DeltaInfo{
|
||||
afterVault.assetsAvailable - beforeVault.assetsAvailable,
|
||||
std::max(
|
||||
scale(afterVault.assetsAvailable, vaultAsset),
|
||||
scale(beforeVault.assetsAvailable, vaultAsset))};
|
||||
auto const minScale = computeMinScale(
|
||||
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
|
||||
|
||||
auto const vaultPseudoDeltaAssets =
|
||||
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
|
||||
|
||||
if (vaultPseudoDeltaAssets >= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
|
||||
"decrease vault balance";
|
||||
@@ -720,15 +814,15 @@ ValidVault::finalize(
|
||||
|
||||
if (!issuerWithdrawal)
|
||||
{
|
||||
auto const accountDeltaAssets = deltaAssetsTxAccount();
|
||||
auto const otherAccountDelta = [&]() -> std::optional<Number> {
|
||||
auto const maybeAccDelta = deltaAssetsTxAccount();
|
||||
auto const maybeOtherAccDelta = [&]() -> std::optional<DeltaInfo> {
|
||||
if (auto const destination = tx[~sfDestination];
|
||||
destination && *destination != tx[sfAccount])
|
||||
return deltaAssets(*destination);
|
||||
return std::nullopt;
|
||||
}();
|
||||
|
||||
if (accountDeltaAssets.has_value() == otherAccountDelta.has_value())
|
||||
if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must change one "
|
||||
@@ -737,9 +831,17 @@ ValidVault::finalize(
|
||||
}
|
||||
|
||||
auto const destinationDelta = //
|
||||
accountDeltaAssets ? *accountDeltaAssets : *otherAccountDelta;
|
||||
maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
|
||||
|
||||
if (destinationDelta <= zero)
|
||||
// the scale of destinationDelta can be coarser than
|
||||
// minScale, so we take that into account when rounding
|
||||
auto const localMinScale =
|
||||
std::max(minScale, computeMinScale(vaultAsset, {destinationDelta}));
|
||||
|
||||
auto const roundedDestinationDelta =
|
||||
roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
|
||||
|
||||
if (roundedDestinationDelta <= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must increase "
|
||||
@@ -747,7 +849,9 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (*vaultDeltaAssets * -1 != destinationDelta)
|
||||
auto const localPseudoDeltaAssets =
|
||||
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
|
||||
if (localPseudoDeltaAssets * -1 != roundedDestinationDelta)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must change vault "
|
||||
@@ -756,6 +860,7 @@ ValidVault::finalize(
|
||||
}
|
||||
}
|
||||
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
|
||||
if (!accountDeltaShares)
|
||||
{
|
||||
@@ -765,7 +870,7 @@ ValidVault::finalize(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (*accountDeltaShares >= zero)
|
||||
if (accountDeltaShares->delta >= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must decrease depositor "
|
||||
@@ -773,15 +878,16 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
|
||||
if (!vaultDeltaShares || *vaultDeltaShares == zero)
|
||||
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must change vault shares";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*vaultDeltaShares * -1 != *accountDeltaShares)
|
||||
if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: withdrawal must change depositor "
|
||||
@@ -789,15 +895,20 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
auto const assetTotalDelta = roundToAsset(
|
||||
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
|
||||
// Note, vaultBalance is negative (see check above)
|
||||
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
|
||||
if (assetTotalDelta != vaultPseudoDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
|
||||
"assets outstanding must add up";
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable)
|
||||
auto const assetAvailableDelta = roundToAsset(
|
||||
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
|
||||
|
||||
if (assetAvailableDelta != vaultPseudoDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
|
||||
"assets available must add up";
|
||||
@@ -828,10 +939,24 @@ ValidVault::finalize(
|
||||
}
|
||||
}
|
||||
|
||||
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
if (vaultDeltaAssets)
|
||||
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
|
||||
if (maybeVaultDeltaAssets)
|
||||
{
|
||||
if (*vaultDeltaAssets >= zero)
|
||||
auto const totalDelta = DeltaInfo{
|
||||
afterVault.assetsTotal - beforeVault.assetsTotal,
|
||||
std::max(
|
||||
scale(afterVault.assetsTotal, vaultAsset),
|
||||
scale(beforeVault.assetsTotal, vaultAsset))};
|
||||
auto const availableDelta = DeltaInfo{
|
||||
afterVault.assetsAvailable - beforeVault.assetsAvailable,
|
||||
std::max(
|
||||
scale(afterVault.assetsAvailable, vaultAsset),
|
||||
scale(beforeVault.assetsAvailable, vaultAsset))};
|
||||
auto const minScale = computeMinScale(
|
||||
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
|
||||
auto const vaultDeltaAssets =
|
||||
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
|
||||
if (vaultDeltaAssets >= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback must decrease vault "
|
||||
@@ -839,7 +964,9 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
|
||||
auto const assetsTotalDelta = roundToAsset(
|
||||
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
|
||||
if (assetsTotalDelta != vaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback and assets outstanding "
|
||||
@@ -847,8 +974,11 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (beforeVault.assetsAvailable + *vaultDeltaAssets !=
|
||||
afterVault.assetsAvailable)
|
||||
auto const assetAvailableDelta = roundToAsset(
|
||||
vaultAsset,
|
||||
afterVault.assetsAvailable - beforeVault.assetsAvailable,
|
||||
minScale);
|
||||
if (assetAvailableDelta != vaultDeltaAssets)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback and assets available "
|
||||
@@ -863,15 +993,15 @@ ValidVault::finalize(
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
auto const accountDeltaShares = deltaShares(tx[sfHolder]);
|
||||
if (!accountDeltaShares)
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const maybeAccountDeltaShares = deltaShares(tx[sfHolder]);
|
||||
if (!maybeAccountDeltaShares)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback must change holder shares";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*accountDeltaShares >= zero)
|
||||
if (maybeAccountDeltaShares->delta >= zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback must decrease holder "
|
||||
@@ -879,15 +1009,16 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
// We don't need to round shares, they are integral MPT
|
||||
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
|
||||
if (!vaultDeltaShares || *vaultDeltaShares == zero)
|
||||
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback must change vault shares";
|
||||
return false; // That's all we can do
|
||||
}
|
||||
|
||||
if (*vaultDeltaShares * -1 != *accountDeltaShares)
|
||||
if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta)
|
||||
{
|
||||
JLOG(j.fatal()) << //
|
||||
"Invariant failed: clawback must change holder and "
|
||||
@@ -924,4 +1055,19 @@ ValidVault::finalize(
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::int32_t
|
||||
ValidVault::computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers)
|
||||
{
|
||||
if (numbers.size() == 0)
|
||||
return 0;
|
||||
|
||||
auto const max =
|
||||
std::max_element(numbers.begin(), numbers.end(), [](auto const& a, auto const& b) -> bool {
|
||||
return a.scale < b.scale;
|
||||
});
|
||||
XRPL_ASSERT_PARTS(
|
||||
max->scale, "xrpl::ValidVault::computeMinScale", "scale set for destinationDelta");
|
||||
return max->scale.value_or(STAmount::cMaxOffset);
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -64,6 +64,50 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
roundToAsset(asset, value, scale, Number::upward);
|
||||
}
|
||||
|
||||
Number
|
||||
computeDefaultCovered(
|
||||
bool useProportionalFormula,
|
||||
std::uint32_t coverRateLiquidation,
|
||||
Number const& coverAvailable,
|
||||
Asset const& vaultAsset,
|
||||
Number const& totalDefaultAmount,
|
||||
Number const& brokerDebtTotal,
|
||||
TenthBips32 coverRateMinimum,
|
||||
std::int32_t loanScale)
|
||||
{
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
Number covered;
|
||||
|
||||
if (useProportionalFormula)
|
||||
{
|
||||
// New formula: DefaultCovered = min(DefaultAmount × CoverRateMinimum,
|
||||
// CoverAvailable)
|
||||
covered = roundToAsset(
|
||||
vaultAsset, tenthBipsOfValue(totalDefaultAmount, coverRateMinimum), loanScale);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Old formula (deprecated by featureLendingProtocolV1_1):
|
||||
// Kept for backwards compatibility with brokers that still carry
|
||||
// sfCoverRateLiquidation.
|
||||
auto const minimumCover = tenthBipsOfValue(brokerDebtTotal, coverRateMinimum);
|
||||
covered = roundToAsset(
|
||||
vaultAsset,
|
||||
/*
|
||||
* This formula is from the XLS-66 spec, section 3.2.3.2 (State
|
||||
* Changes), specifically "if the `tfLoanDefault` flag is set" /
|
||||
* "Apply the First-Loss Capital to the Default Amount"
|
||||
*/
|
||||
std::min(
|
||||
tenthBipsOfValue(minimumCover, TenthBips32{coverRateLiquidation}),
|
||||
totalDefaultAmount),
|
||||
loanScale);
|
||||
}
|
||||
|
||||
return std::min(covered, coverAvailable);
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
void
|
||||
|
||||
@@ -123,7 +123,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
return roundToAsset(
|
||||
vaultAsset,
|
||||
tenthBipsOfValue(currentDebtTotal, TenthBips32(sleBroker->at(sfCoverRateMinimum))),
|
||||
currentDebtTotal.exponent());
|
||||
scale(currentDebtTotal, vaultAsset));
|
||||
}();
|
||||
if (coverAvail < amount)
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
|
||||
@@ -49,6 +49,9 @@ LoanBrokerSet::preflight(PreflightContext const& ctx)
|
||||
return temINVALID;
|
||||
}
|
||||
|
||||
// sfCoverRateLiquidation is deprecated by featureLendingProtocolV1_1;
|
||||
// only enforce consistency when the amendment is not enabled.
|
||||
if (!ctx.rules.enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
auto const minimumZero = tx[~sfCoverRateMinimum].value_or(0) == 0;
|
||||
auto const liquidationZero = tx[~sfCoverRateLiquidation].value_or(0) == 0;
|
||||
@@ -249,7 +252,8 @@ LoanBrokerSet::doApply()
|
||||
broker->at(sfDebtMaximum) = *debtMax;
|
||||
if (auto const coverMin = tx[~sfCoverRateMinimum])
|
||||
broker->at(sfCoverRateMinimum) = *coverMin;
|
||||
if (auto const coverLiq = tx[~sfCoverRateLiquidation])
|
||||
if (auto const coverLiq = tx[~sfCoverRateLiquidation];
|
||||
coverLiq && !view.rules().enabled(featureLendingProtocolV1_1))
|
||||
broker->at(sfCoverRateLiquidation) = *coverLiq;
|
||||
|
||||
view.insert(broker);
|
||||
|
||||
@@ -141,25 +141,16 @@ LoanManage::defaultLoan(
|
||||
|
||||
// Apply the First-Loss Capital to the Default Amount
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
TenthBips32 const coverRateLiquidation{brokerSle->at(sfCoverRateLiquidation)};
|
||||
auto const defaultCovered = [&]() {
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
auto const minimumCover = tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum);
|
||||
// Round the liquidation amount up, too
|
||||
auto const covered = roundToAsset(
|
||||
vaultAsset,
|
||||
/*
|
||||
* This formula is from the XLS-66 spec, section 3.2.3.2 (State
|
||||
* Changes), specifically "if the `tfLoanDefault` flag is set" /
|
||||
* "Apply the First-Loss Capital to the Default Amount"
|
||||
*/
|
||||
std::min(tenthBipsOfValue(minimumCover, coverRateLiquidation), totalDefaultAmount),
|
||||
loanScale);
|
||||
auto const coverAvailable = *brokerSle->at(sfCoverAvailable);
|
||||
|
||||
return std::min(covered, coverAvailable);
|
||||
}();
|
||||
auto const defaultCovered = computeDefaultCovered(
|
||||
useProportionalDefaultCover(view.rules(), brokerSle),
|
||||
brokerSle->at(sfCoverRateLiquidation),
|
||||
*brokerSle->at(sfCoverAvailable),
|
||||
vaultAsset,
|
||||
totalDefaultAmount,
|
||||
brokerDebtTotalProxy.value(),
|
||||
coverRateMinimum,
|
||||
loanScale);
|
||||
|
||||
auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered;
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ VaultDelete::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
|
||||
return temDISABLED;
|
||||
|
||||
// The sfMemoData field is an optional field used to record the deletion reason.
|
||||
if (auto const data = ctx.tx[~sfMemoData]; data && !validDataLength(data, maxDataPayloadLength))
|
||||
return temMALFORMED;
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,14 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/apply.h>
|
||||
#include <xrpl/tx/invariants/InvariantCheck.h>
|
||||
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl {
|
||||
namespace test {
|
||||
|
||||
@@ -3805,6 +3810,128 @@ class Invariants_test : public beast::unit_test::suite
|
||||
precloseMpt);
|
||||
}
|
||||
|
||||
void
|
||||
testVaultComputeMinScale()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
PrettyAsset const vaultAsset = issuer["IOU"];
|
||||
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
std::int32_t expectedMinScale;
|
||||
std::vector<ValidVault::DeltaInfo> values;
|
||||
};
|
||||
|
||||
NumberMantissaScaleGuard g{MantissaRange::large};
|
||||
|
||||
auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
|
||||
return {n, scale(n, vaultAsset.raw())};
|
||||
};
|
||||
|
||||
auto const testCases = std::vector<TestCase>{
|
||||
{
|
||||
.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 computeMinScale: " + tc.name);
|
||||
|
||||
auto const actualScale = ValidVault::computeMinScale(vaultAsset, 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<TestCase>{
|
||||
{
|
||||
.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 computeMinScale: " + tc.name);
|
||||
|
||||
auto const actualScale = ValidVault::computeMinScale(vaultAsset, tc.values);
|
||||
|
||||
BEAST_EXPECTS(
|
||||
actualScale == tc.expectedMinScale,
|
||||
"expected: " + std::to_string(tc.expectedMinScale) +
|
||||
", actual: " + std::to_string(actualScale));
|
||||
std::optional<Number> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -3830,6 +3957,7 @@ public:
|
||||
testValidPseudoAccounts();
|
||||
testValidLoanBroker();
|
||||
testVault();
|
||||
testVaultComputeMinScale();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <test/jtx.h>
|
||||
#include <test/jtx/Account.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/mpt.h>
|
||||
|
||||
#include <xrpl/beast/xor_shift_engine.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
@@ -1186,6 +1185,199 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
|
||||
}
|
||||
|
||||
void
|
||||
testComputeDefaultCovered()
|
||||
{
|
||||
testcase("computeDefaultCovered");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
// ---- Common parameters ----
|
||||
Asset const asset{xrpIssue()};
|
||||
std::int32_t const loanScale = 1;
|
||||
// coverRateLiquidation value used by old-formula tests (100%).
|
||||
std::uint32_t const covRateLiq = 100'000;
|
||||
|
||||
// ---- Test 1: New formula basic ----
|
||||
// DefaultCovered = min(DefaultAmount × CoverRateMinimum,
|
||||
// CoverAvailable)
|
||||
// 100,000 × 20% = 20,000; min(20,000, 50,000) = 20,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
true, // useProportionalFormula
|
||||
0, // coverRateLiquidation (unused)
|
||||
Number{50'000}, // coverAvailable
|
||||
asset,
|
||||
Number{100'000}, // totalDefaultAmount
|
||||
Number{200'000}, // brokerDebtTotal (unused)
|
||||
TenthBips32{20'000}, // 20%
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{20'000});
|
||||
}
|
||||
|
||||
// ---- Test 2: New formula capped by CoverAvailable ----
|
||||
// 100,000 × 50% = 50,000; min(50,000, 10,000) = 10,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
true,
|
||||
0,
|
||||
Number{10'000},
|
||||
asset,
|
||||
Number{100'000},
|
||||
Number{200'000},
|
||||
TenthBips32{50'000}, // 50%
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{10'000});
|
||||
}
|
||||
|
||||
// ---- Test 3: Old formula basic ----
|
||||
// min(CovRateLiq × (CovRateMin × BrokerDebtTotal), DefaultAmount)
|
||||
// minimumCover = 200,000 × 20% = 40,000
|
||||
// covered = min(100% × 40,000, 50,000) = 40,000
|
||||
// min(40,000, 100,000) = 40,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
false, // old formula
|
||||
covRateLiq, // 100%
|
||||
Number{100'000},
|
||||
asset,
|
||||
Number{50'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{40'000});
|
||||
}
|
||||
|
||||
// ---- Test 4: Old formula capped by DefaultAmount ----
|
||||
// minimumCover = 200,000 × 50% = 100,000
|
||||
// covered = min(100% × 100,000, 30,000) = 30,000
|
||||
// min(30,000, 500,000) = 30,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
false,
|
||||
covRateLiq,
|
||||
Number{500'000},
|
||||
asset,
|
||||
Number{30'000},
|
||||
Number{200'000},
|
||||
TenthBips32{50'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{30'000});
|
||||
}
|
||||
|
||||
// ---- Test 5: Old formula capped by CoverAvailable ----
|
||||
// minimumCover = 200,000 × 20% = 40,000
|
||||
// covered = min(100% × 40,000, 100,000) = 40,000
|
||||
// min(40,000, 5,000) = 5,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
false,
|
||||
covRateLiq,
|
||||
Number{5'000}, // small CoverAvailable
|
||||
asset,
|
||||
Number{100'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{5'000});
|
||||
}
|
||||
|
||||
// ---- Test 6: Backwards compatibility ----
|
||||
// useProportionalFormula = false even though the amendment is
|
||||
// enabled, because the broker was created before the amendment
|
||||
// and still carries sfCoverRateLiquidation. The caller passes
|
||||
// false in this case; we verify the old formula is used.
|
||||
// minimumCover = 200,000 × 20% = 40,000
|
||||
// covered = min(100% × 40,000, 50,000) = 40,000
|
||||
// min(40,000, 100,000) = 40,000
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
false, // old formula (backwards compat)
|
||||
covRateLiq,
|
||||
Number{100'000},
|
||||
asset,
|
||||
Number{50'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{40'000});
|
||||
}
|
||||
|
||||
// ---- Test 7: New vs old produce different results ----
|
||||
// Same inputs, different formula selection → different outputs.
|
||||
{
|
||||
auto resultNew = computeDefaultCovered(
|
||||
true,
|
||||
0,
|
||||
Number{100'000},
|
||||
asset,
|
||||
Number{50'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
|
||||
auto resultOld = computeDefaultCovered(
|
||||
false,
|
||||
covRateLiq,
|
||||
Number{100'000},
|
||||
asset,
|
||||
Number{50'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
|
||||
// New: 50,000 × 20% = 10,000
|
||||
BEAST_EXPECT(resultNew == Number{10'000});
|
||||
// Old: min(100% × (20% × 200,000), 50,000)
|
||||
// = min(40,000, 50,000) = 40,000
|
||||
BEAST_EXPECT(resultOld == Number{40'000});
|
||||
BEAST_EXPECT(resultNew != resultOld);
|
||||
}
|
||||
|
||||
// ---- Test 8: Zero CoverAvailable ----
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
true,
|
||||
0,
|
||||
Number{0},
|
||||
asset,
|
||||
Number{100'000},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{0});
|
||||
}
|
||||
|
||||
// ---- Test 9: Zero DefaultAmount ----
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
true,
|
||||
0,
|
||||
Number{50'000},
|
||||
asset,
|
||||
Number{0},
|
||||
Number{200'000},
|
||||
TenthBips32{20'000},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{0});
|
||||
}
|
||||
|
||||
// ---- Test 10: Zero CoverRateMinimum (new formula) ----
|
||||
// 100,000 × 0% = 0
|
||||
{
|
||||
auto result = computeDefaultCovered(
|
||||
true,
|
||||
0,
|
||||
Number{50'000},
|
||||
asset,
|
||||
Number{100'000},
|
||||
Number{200'000},
|
||||
TenthBips32{0},
|
||||
loanScale);
|
||||
BEAST_EXPECT(result == Number{0});
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -1206,6 +1398,7 @@ public:
|
||||
testComputePaymentFactor();
|
||||
testComputeOverpaymentComponents();
|
||||
testComputeInterestAndFeeParts();
|
||||
testComputeDefaultCovered();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <test/jtx.h>
|
||||
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/tx/transactors/lending/LendingHelpers.h>
|
||||
#include <xrpl/tx/transactors/lending/LoanBrokerCoverDeposit.h>
|
||||
|
||||
namespace xrpl {
|
||||
@@ -484,14 +485,14 @@ class LoanBroker_test : public beast::unit_test::suite
|
||||
}
|
||||
|
||||
void
|
||||
testLifecycle()
|
||||
testLifecycle(FeatureBitset features)
|
||||
{
|
||||
testcase("Lifecycle");
|
||||
using namespace jtx;
|
||||
|
||||
// Create 3 loan brokers: one for XRP, one for an IOU, and one for an
|
||||
// MPT. That'll require three corresponding SAVs.
|
||||
Env env(*this, all);
|
||||
Env env(*this, features);
|
||||
|
||||
Account issuer{"issuer"};
|
||||
// For simplicity, alice will be the sole actor for the vault & brokers.
|
||||
@@ -634,18 +635,22 @@ class LoanBroker_test : public beast::unit_test::suite
|
||||
ter(temINVALID));
|
||||
// Cover: zero min, non-zero liquidation - implicit and
|
||||
// explicit zero values.
|
||||
env(set(evan, vault.vaultID), coverRateLiquidation(maxCoverRate), ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
coverRateLiquidation(maxCoverRate),
|
||||
env.enabled(featureLendingProtocolV1_1) ? ter(tecNO_PERMISSION) : ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
coverRateMinimum(tenthBipsZero),
|
||||
coverRateLiquidation(maxCoverRate),
|
||||
ter(temINVALID));
|
||||
env.enabled(featureLendingProtocolV1_1) ? ter(tecNO_PERMISSION) : ter(temINVALID));
|
||||
// Cover: non-zero min, zero liquidation - implicit and
|
||||
// explicit zero values.
|
||||
env(set(evan, vault.vaultID), coverRateMinimum(maxCoverRate), ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
coverRateMinimum(maxCoverRate),
|
||||
env.enabled(featureLendingProtocolV1_1) ? ter(tecNO_PERMISSION) : ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
coverRateMinimum(maxCoverRate),
|
||||
coverRateLiquidation(tenthBipsZero),
|
||||
ter(temINVALID));
|
||||
env.enabled(featureLendingProtocolV1_1) ? ter(tecNO_PERMISSION) : ter(temINVALID));
|
||||
// sfDebtMaximum: good value, bad account
|
||||
env(set(evan, vault.vaultID), debtMaximum(Number(0)), ter(tecNO_PERMISSION));
|
||||
// sfDebtMaximum: overflow
|
||||
@@ -773,7 +778,15 @@ class LoanBroker_test : public beast::unit_test::suite
|
||||
// Extra checks
|
||||
BEAST_EXPECT(broker->at(sfManagementFeeRate) == 123);
|
||||
BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 100);
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), broker))
|
||||
{
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200);
|
||||
}
|
||||
|
||||
BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(9));
|
||||
BEAST_EXPECT(checkVL(broker->at(sfData), testData));
|
||||
},
|
||||
@@ -1770,15 +1783,27 @@ class LoanBroker_test : public beast::unit_test::suite
|
||||
testRIPD4274MPT();
|
||||
}
|
||||
|
||||
void
|
||||
testFeatureLendingProtocolV1_1enabled()
|
||||
{
|
||||
using namespace jtx;
|
||||
testcase("featureLendingProtocolV1_1 enabled");
|
||||
Env env{*this};
|
||||
|
||||
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testFeatureLendingProtocolV1_1enabled();
|
||||
testLoanBrokerSetDebtMaximum();
|
||||
testLoanBrokerCoverDepositNullVault();
|
||||
|
||||
testDisabled();
|
||||
testLifecycle();
|
||||
testLifecycle(all);
|
||||
testLifecycle(all - featureLendingProtocolV1_1);
|
||||
testInvalidLoanBrokerCoverClawback();
|
||||
testInvalidLoanBrokerCoverDeposit();
|
||||
testInvalidLoanBrokerCoverWithdraw();
|
||||
|
||||
@@ -1944,14 +1944,15 @@ protected:
|
||||
: std::max(
|
||||
broker.vaultScale(env), state.principalOutstanding.exponent())));
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
auto const defaultAmount = roundToAsset(
|
||||
auto const totalDefaultAmount = state.totalValue - state.managementFeeOutstanding;
|
||||
auto const defaultAmount = computeDefaultCovered(
|
||||
useProportionalDefaultCover(env.current()->rules(), brokerSle),
|
||||
brokerSle->at(~sfCoverRateLiquidation).value_or(0),
|
||||
brokerSle->at(sfCoverAvailable),
|
||||
broker.asset,
|
||||
std::min(
|
||||
tenthBipsOfValue(
|
||||
tenthBipsOfValue(
|
||||
brokerSle->at(sfDebtTotal), broker.params.coverRateMin),
|
||||
broker.params.coverRateLiquidation),
|
||||
state.totalValue - state.managementFeeOutstanding),
|
||||
totalDefaultAmount,
|
||||
brokerSle->at(sfDebtTotal),
|
||||
broker.params.coverRateMin,
|
||||
state.loanScale);
|
||||
return std::make_pair(defaultAmount, brokerSle->at(sfOwner));
|
||||
}
|
||||
@@ -2665,7 +2666,7 @@ protected:
|
||||
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION));
|
||||
});
|
||||
|
||||
#if LOANTODO
|
||||
#if LOAN_TODO
|
||||
// TODO
|
||||
|
||||
/*
|
||||
@@ -5326,7 +5327,7 @@ protected:
|
||||
}
|
||||
}
|
||||
|
||||
#if LOANTODO
|
||||
#if LOAN_TODO
|
||||
void
|
||||
testLoanPayLateFullPaymentBypassesPenalties()
|
||||
{
|
||||
@@ -6863,7 +6864,7 @@ protected:
|
||||
}
|
||||
|
||||
void
|
||||
testSequentialFLCDepletion()
|
||||
testSequentialFLCDepletion(FeatureBitset features)
|
||||
{
|
||||
testcase << "First-Loss Capital Depletion on Sequential Defaults";
|
||||
|
||||
@@ -6871,7 +6872,7 @@ protected:
|
||||
using namespace loan;
|
||||
using namespace loanBroker;
|
||||
|
||||
Env env(*this, all);
|
||||
Env env(*this, features);
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
@@ -6899,11 +6900,13 @@ protected:
|
||||
});
|
||||
auto const brokerKeylet = brokerInfo.brokerKeylet();
|
||||
|
||||
// Create two identical loans: each 50,000 XRP principal (scaled down to
|
||||
// avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal
|
||||
// + interest) Formula will calculate cover as: 100% × (20% × 100,000) =
|
||||
// 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first
|
||||
// default
|
||||
// Create two identical loans: each 50,000 XRP principal.
|
||||
// Total BrokerDebtTotal will be ~104,201 (principal + interest + fees).
|
||||
// Old formula: seizure = min(100% × (20% × BrokerDebtTotal),
|
||||
// DefaultAmount) ≈ 20,054 — nearly depleting 21,000 FLC on the
|
||||
// first default.
|
||||
// New formula: seizure = DefaultAmount × 20% ≈ 10,027 — splitting
|
||||
// FLC equitably across both defaults.
|
||||
auto const principalAmount = Number(50'000);
|
||||
auto const loanPaymentInterval = 2592000; // 30 days
|
||||
auto const loanGracePeriod = 604800; // 7 days
|
||||
@@ -6962,13 +6965,42 @@ protected:
|
||||
auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
|
||||
auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
|
||||
|
||||
// DebtTotal should have decreased by Loan A's debt
|
||||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), brokerSle))
|
||||
{
|
||||
// Proportional default cover (new formula):
|
||||
// DefaultCovered = min(DefaultAmount × CoverRateMinimum,
|
||||
// CoverAvailable)
|
||||
// Loan A's DefaultAmount (~52,067) × 20% = 10,027 seizure
|
||||
// Result: CoverAvailable = 21,000 - 10,027 = 10,973
|
||||
|
||||
// CoverAvailable should have decreased significantly
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 946);
|
||||
// DebtTotal should have decreased by Loan A's debt (~52,067),
|
||||
// leaving only Loan B's debt (~50,134).
|
||||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||||
|
||||
// CoverAvailable should have decreased proportionally
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 10'973);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Global default cover (old formula):
|
||||
// DefaultCovered = min(CoverRateLiquidation ×
|
||||
// (CoverRateMinimum × BrokerDebtTotal), DefaultAmount)
|
||||
// Pre-default BrokerDebtTotal (~104,201) × 20% = 20,840
|
||||
// then 100% × 20,840 = 20,840
|
||||
// but capped at DefaultAmount (~52,067), seizure = 20,054
|
||||
// Result: CoverAvailable = 21,000 - 20,054 = 946
|
||||
|
||||
// DebtTotal should have decreased by Loan A's debt (~52,067),
|
||||
// leaving only Loan B's debt (~50,134).
|
||||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||||
|
||||
// CoverAvailable should have decreased significantly
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 946);
|
||||
}
|
||||
|
||||
// Default Loan B
|
||||
env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
brokerSle = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(brokerSle))
|
||||
@@ -6976,19 +7008,370 @@ protected:
|
||||
auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal);
|
||||
auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable);
|
||||
|
||||
// Both scenarios: DebtTotal should be 0 after both loans default
|
||||
BEAST_EXPECT(afterSecondDebtTotal == 0);
|
||||
|
||||
BEAST_EXPECT(afterSecondCoverAvailable == 0);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), brokerSle))
|
||||
{
|
||||
// Proportional default cover (new formula):
|
||||
// Loan B's DefaultAmount (~50,134) × 20% = 10,027 seizure
|
||||
// Result: CoverAvailable = 10,973 - 10,027 = 946
|
||||
//
|
||||
// Both loans are covered equitably with a safety buffer remaining.
|
||||
BEAST_EXPECT(afterSecondCoverAvailable == 946);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Global default cover (old formula):
|
||||
// Only 946 remains to cover Loan B's DefaultAmount (~50,134)
|
||||
// Result: CoverAvailable = 0 (fully depleted)
|
||||
BEAST_EXPECT(afterSecondCoverAvailable == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that vault withdrawals work correctly when the vault has unrealized
|
||||
// loss from an impaired loan, ensuring the invariant check properly
|
||||
// accounts for the loss.
|
||||
void
|
||||
testWithdrawReflectsUnrealizedLoss()
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
testcase("Vault withdraw reflects sfLossUnrealized");
|
||||
|
||||
// Test constants
|
||||
static constexpr std::int64_t INITIAL_FUNDING = 1'000'000;
|
||||
static constexpr std::int64_t LENDER_INITIAL_IOU = 5'000'000;
|
||||
static constexpr std::int64_t DEPOSITOR_INITIAL_IOU = 1'000'000;
|
||||
static constexpr std::int64_t BORROWER_INITIAL_IOU = 100'000;
|
||||
static constexpr std::int64_t DEPOSIT_AMOUNT = 5'000;
|
||||
static constexpr std::int64_t PRINCIPAL_AMOUNT = 99;
|
||||
static constexpr std::uint64_t EXPECTED_SHARES_PER_DEPOSITOR = 5'000'000'000;
|
||||
static constexpr std::uint32_t PAYMENT_INTERVAL = 600;
|
||||
static constexpr std::uint32_t PAYMENT_TOTAL = 2;
|
||||
|
||||
Env env(*this, all);
|
||||
|
||||
// Setup accounts
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const depositorA{"lpA"};
|
||||
Account const depositorB{"lpB"};
|
||||
Account const borrower{"borrowerA"};
|
||||
|
||||
env.fund(XRP(INITIAL_FUNDING), issuer, lender, depositorA, depositorB, borrower);
|
||||
env.close();
|
||||
|
||||
// Setup trust lines
|
||||
PrettyAsset const iouAsset = issuer[iouCurrency];
|
||||
env(trust(lender, iouAsset(10'000'000)));
|
||||
env(trust(depositorA, iouAsset(10'000'000)));
|
||||
env(trust(depositorB, iouAsset(10'000'000)));
|
||||
env(trust(borrower, iouAsset(10'000'000)));
|
||||
env.close();
|
||||
|
||||
// Fund accounts with IOUs
|
||||
env(pay(issuer, lender, iouAsset(LENDER_INITIAL_IOU)));
|
||||
env(pay(issuer, depositorA, iouAsset(DEPOSITOR_INITIAL_IOU)));
|
||||
env(pay(issuer, depositorB, iouAsset(DEPOSITOR_INITIAL_IOU)));
|
||||
env(pay(issuer, borrower, iouAsset(BORROWER_INITIAL_IOU)));
|
||||
env.close();
|
||||
|
||||
// Create vault and broker, then add deposits from two depositors
|
||||
auto const broker = createVaultAndBroker(env, iouAsset, lender);
|
||||
Vault v{env};
|
||||
|
||||
env(v.deposit({
|
||||
.depositor = depositorA,
|
||||
.id = broker.vaultKeylet().key,
|
||||
.amount = iouAsset(DEPOSIT_AMOUNT),
|
||||
}),
|
||||
ter(tesSUCCESS));
|
||||
env(v.deposit({
|
||||
.depositor = depositorB,
|
||||
.id = broker.vaultKeylet().key,
|
||||
.amount = iouAsset(DEPOSIT_AMOUNT),
|
||||
}),
|
||||
ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
// Create a loan
|
||||
auto const sleBroker = env.le(keylet::loanbroker(broker.brokerID));
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
|
||||
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
|
||||
|
||||
env(set(borrower, broker.brokerID, PRINCIPAL_AMOUNT),
|
||||
sig(sfCounterpartySignature, lender),
|
||||
paymentTotal(PAYMENT_TOTAL),
|
||||
paymentInterval(PAYMENT_INTERVAL),
|
||||
fee(env.current()->fees().base * 2),
|
||||
ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
// Impair the loan to create unrealized loss
|
||||
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
// Verify unrealized loss is recorded in the vault
|
||||
auto const vaultAfterImpair = env.le(broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultAfterImpair))
|
||||
return;
|
||||
|
||||
BEAST_EXPECT(
|
||||
vaultAfterImpair->at(sfLossUnrealized) == broker.asset(PRINCIPAL_AMOUNT).value());
|
||||
|
||||
// Helper to get share balance for a depositor
|
||||
auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
|
||||
auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
|
||||
auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
|
||||
return token ? token->getFieldU64(sfMPTAmount) : 0;
|
||||
};
|
||||
|
||||
// Verify both depositors have equal shares
|
||||
auto const sharesLpA = getShareBalance(depositorA);
|
||||
auto const sharesLpB = getShareBalance(depositorB);
|
||||
BEAST_EXPECT(sharesLpA == EXPECTED_SHARES_PER_DEPOSITOR);
|
||||
BEAST_EXPECT(sharesLpB == EXPECTED_SHARES_PER_DEPOSITOR);
|
||||
BEAST_EXPECT(sharesLpA == sharesLpB);
|
||||
|
||||
// Helper to attempt withdrawal
|
||||
auto const attemptWithdrawShares = [&](Account const& depositor,
|
||||
std::uint64_t shareAmount,
|
||||
TER expected) {
|
||||
STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
|
||||
env(v.withdraw(
|
||||
{.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
|
||||
ter(expected));
|
||||
env.close();
|
||||
};
|
||||
|
||||
// Regression test: Both depositors should successfully withdraw despite
|
||||
// unrealized loss. Previously failed with invariant violation:
|
||||
// "withdrawal must change vault and destination balance by equal
|
||||
// amount". This was caused by sharesToAssetsWithdraw rounding down,
|
||||
// creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
|
||||
// when unrealized loss exists.
|
||||
attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
|
||||
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
|
||||
}
|
||||
|
||||
void
|
||||
testCoverRateLiquidationAmendmentGating()
|
||||
{
|
||||
testcase("CoverRateLiquidation amendment gating");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loanBroker;
|
||||
|
||||
auto const coverRateLiqValue = percentageToTenthBips(25);
|
||||
|
||||
// When featureLendingProtocolV1_1 is NOT enabled,
|
||||
// sfCoverRateLiquidation should be recorded on the broker.
|
||||
{
|
||||
Env env(*this, all - featureLendingProtocolV1_1);
|
||||
|
||||
Account const lender{"lender"};
|
||||
env.fund(XRP(10'000'000), lender);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
|
||||
BrokerParameters brokerParams{.coverRateLiquidation = coverRateLiqValue};
|
||||
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||||
if (BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
BEAST_EXPECT(brokerSle->isFieldPresent(sfCoverRateLiquidation));
|
||||
BEAST_EXPECT(brokerSle->at(sfCoverRateLiquidation) == coverRateLiqValue.value());
|
||||
}
|
||||
}
|
||||
|
||||
// When featureLendingProtocolV1_1 IS enabled,
|
||||
// sfCoverRateLiquidation should NOT be recorded on the broker.
|
||||
{
|
||||
Env env(*this, all);
|
||||
|
||||
Account const lender{"lender"};
|
||||
env.fund(XRP(10'000'000), lender);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
|
||||
BrokerParameters brokerParams{.coverRateLiquidation = coverRateLiqValue};
|
||||
BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
|
||||
if (BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
BEAST_EXPECT(!brokerSle->isFieldPresent(sfCoverRateLiquidation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testCoverRateLiquidationBackwardsCompat()
|
||||
{
|
||||
testcase("CoverRateLiquidation backwards compatibility on default");
|
||||
|
||||
// Verify that the default cover formula honours whether
|
||||
// sfCoverRateLiquidation is present on the broker SLE,
|
||||
// regardless of the amendment state:
|
||||
//
|
||||
// * A broker created BEFORE the amendment has the field →
|
||||
// old formula (global) is used even after the amendment
|
||||
// is enabled.
|
||||
//
|
||||
// * A broker created AFTER the amendment lacks the field →
|
||||
// new formula (proportional) is used.
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace loanBroker;
|
||||
|
||||
// ---- helpers shared by both sub-tests ----
|
||||
auto const coverRateMin = TenthBips32(20'000); // 20 %
|
||||
auto const coverRateLiq = percentageToTenthBips(25); // 25 % (default)
|
||||
auto const principalAmount = Number(50'000);
|
||||
auto const loanPaymentInterval = 2'592'000; // 30 days
|
||||
auto const loanGracePeriod = 604'800; // 7 days
|
||||
|
||||
// Lambda that creates one loan, advances past the grace period,
|
||||
// defaults it, and returns the CoverAvailable after default.
|
||||
auto defaultOneLoan = [&](Env& env,
|
||||
BrokerInfo const& brokerInfo,
|
||||
Account const& lender,
|
||||
Account const& borrower) -> Number {
|
||||
auto const brokerKeylet = brokerInfo.brokerKeylet();
|
||||
|
||||
auto loanTx = env.jt(
|
||||
set(borrower, brokerKeylet.key, principalAmount),
|
||||
sig(sfCounterpartySignature, lender),
|
||||
interestRate(TenthBips32(500)), // 5 %
|
||||
paymentTotal(12),
|
||||
loan::paymentInterval(loanPaymentInterval),
|
||||
loan::gracePeriod(loanGracePeriod),
|
||||
fee(XRP(10)));
|
||||
env(loanTx);
|
||||
env.close();
|
||||
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
|
||||
auto loanSle = env.le(loanKeylet);
|
||||
if (!BEAST_EXPECT(loanSle))
|
||||
return Number{-1};
|
||||
|
||||
auto const nextDue = loanSle->at(sfNextPaymentDueDate);
|
||||
auto const grace = loanSle->at(sfGracePeriod);
|
||||
env.close(std::chrono::seconds{nextDue + grace + 60});
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto brokerSle = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(brokerSle))
|
||||
return Number{-1};
|
||||
return brokerSle->at(sfCoverAvailable);
|
||||
};
|
||||
|
||||
// ---- Sub-test A: pre-amendment broker (old formula) ----
|
||||
Number coverAfterOld;
|
||||
{
|
||||
// Start WITHOUT the amendment so the broker stores the field.
|
||||
Env env(*this, all - featureLendingProtocolV1_1);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = xrpIssue();
|
||||
auto const brokerInfo = createVaultAndBroker(
|
||||
env,
|
||||
asset,
|
||||
lender,
|
||||
{
|
||||
.vaultDeposit = Number(200'000),
|
||||
.debtMax = 0,
|
||||
.coverRateMin = coverRateMin,
|
||||
.coverDeposit = 21'000,
|
||||
.managementFeeRate = TenthBips16(100),
|
||||
.coverRateLiquidation = coverRateLiq,
|
||||
});
|
||||
|
||||
// Confirm the field was stored.
|
||||
{
|
||||
auto sle = env.le(brokerInfo.brokerKeylet());
|
||||
BEAST_EXPECT(sle && sle->isFieldPresent(sfCoverRateLiquidation));
|
||||
}
|
||||
|
||||
// Now enable the amendment – the broker keeps its field.
|
||||
env.enableFeature(featureLendingProtocolV1_1);
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
|
||||
|
||||
coverAfterOld = defaultOneLoan(env, brokerInfo, lender, borrower);
|
||||
}
|
||||
|
||||
// ---- Sub-test B: post-amendment broker (new formula) ----
|
||||
Number coverAfterNew;
|
||||
{
|
||||
Env env(*this, all); // amendment already enabled
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = xrpIssue();
|
||||
// Pass coverRateLiquidation in BrokerParameters, but the
|
||||
// amendment-gated code will NOT store it on the SLE.
|
||||
auto const brokerInfo = createVaultAndBroker(
|
||||
env,
|
||||
asset,
|
||||
lender,
|
||||
{
|
||||
.vaultDeposit = Number(200'000),
|
||||
.debtMax = 0,
|
||||
.coverRateMin = coverRateMin,
|
||||
.coverDeposit = 21'000,
|
||||
.managementFeeRate = TenthBips16(100),
|
||||
.coverRateLiquidation = coverRateLiq,
|
||||
});
|
||||
|
||||
// Confirm the field was NOT stored.
|
||||
{
|
||||
auto sle = env.le(brokerInfo.brokerKeylet());
|
||||
BEAST_EXPECT(sle && !sle->isFieldPresent(sfCoverRateLiquidation));
|
||||
}
|
||||
|
||||
coverAfterNew = defaultOneLoan(env, brokerInfo, lender, borrower);
|
||||
}
|
||||
|
||||
// Old (global) formula with 25% CoverRateLiquidation:
|
||||
// min(25% × (20% × 50,134), 50,134) = min(2,507, 50,134)
|
||||
// = 2,507 seized → CoverAvailable = 21,000 - 2,507 = 18,493
|
||||
BEAST_EXPECT(coverAfterOld == Number{18'493});
|
||||
|
||||
// New (proportional) formula:
|
||||
// min(20% × 50,134, 21,000) = min(10,027, 21,000)
|
||||
// = 10,027 seized → CoverAvailable = 21,000 - 10,027 = 10,973
|
||||
BEAST_EXPECT(coverAfterNew == Number{10'973});
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
#if LOANTODO
|
||||
#if LOAN_TODO
|
||||
testLoanPayLateFullPaymentBypassesPenalties();
|
||||
testLoanCoverMinimumRoundingExploit();
|
||||
#endif
|
||||
testWithdrawReflectsUnrealizedLoss();
|
||||
testInvalidLoanSet();
|
||||
|
||||
testCoverDepositWithdrawNonTransferableMPT();
|
||||
@@ -7033,7 +7416,10 @@ public:
|
||||
testLoanPayBrokerOwnerUnauthorizedMPT();
|
||||
testLoanPayBrokerOwnerNoPermissionedDomainMPT();
|
||||
testLoanSetBrokerOwnerNoPermissionedDomainMPT();
|
||||
testSequentialFLCDepletion();
|
||||
testSequentialFLCDepletion(all - featureLendingProtocolV1_1);
|
||||
testSequentialFLCDepletion(all);
|
||||
testCoverRateLiquidationAmendmentGating();
|
||||
testCoverRateLiquidationBackwardsCompat();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1081,6 +1081,7 @@ class Vault_test : public beast::unit_test::suite
|
||||
Asset const& asset,
|
||||
Vault& vault)> test) {
|
||||
Env env{*this, testable_amendments() | featureSingleAssetVault};
|
||||
|
||||
Account issuer{"issuer"};
|
||||
Account owner{"owner"};
|
||||
Account depositor{"depositor"};
|
||||
@@ -5231,6 +5232,63 @@ class Vault_test : public beast::unit_test::suite
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testVaultDeleteData()
|
||||
{
|
||||
using namespace test::jtx;
|
||||
|
||||
Env env{*this};
|
||||
|
||||
Account const owner{"owner"};
|
||||
env.fund(XRP(1'000'000), owner);
|
||||
env.close();
|
||||
|
||||
Vault vault{env};
|
||||
|
||||
auto const keylet = keylet::vault(owner.id(), 1);
|
||||
auto delTx = vault.del({.owner = owner, .id = keylet.key});
|
||||
|
||||
// Test VaultDelete with featureLendingProtocolV1_1 disabled
|
||||
// Transaction fails if the data field is provided
|
||||
{
|
||||
testcase("VaultDelete data featureLendingProtocolV1_1 disabled");
|
||||
env.disableFeature(featureLendingProtocolV1_1);
|
||||
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
|
||||
env(delTx, ter(temDISABLED));
|
||||
env.close();
|
||||
env.enableFeature(featureLendingProtocolV1_1);
|
||||
}
|
||||
|
||||
// Transaction fails if the data field is too large
|
||||
{
|
||||
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data too large");
|
||||
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength + 1, 'A'));
|
||||
env(delTx, ter(temMALFORMED));
|
||||
env.close();
|
||||
}
|
||||
|
||||
// Transaction fails if the data field is set, but is empty
|
||||
{
|
||||
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data empty");
|
||||
delTx[sfMemoData] = strHex(std::string(0, 'A'));
|
||||
env(delTx, ter(temMALFORMED));
|
||||
env.close();
|
||||
}
|
||||
|
||||
{
|
||||
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data valid");
|
||||
PrettyAsset const xrpAsset = xrpIssue();
|
||||
auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
|
||||
env(tx, ter(tesSUCCESS));
|
||||
env.close();
|
||||
// Recreate the transaction as the vault keylet changed
|
||||
auto delTx = vault.del({.owner = owner, .id = keylet.key});
|
||||
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
|
||||
env(delTx, ter(tesSUCCESS));
|
||||
env.close();
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -5251,6 +5309,7 @@ public:
|
||||
testVaultClawbackBurnShares();
|
||||
testVaultClawbackAssets();
|
||||
testAssetsMaximum();
|
||||
testVaultDeleteData();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
|
||||
|
||||
// Transaction-specific field values
|
||||
auto const vaultIDValue = canonical_UINT256();
|
||||
auto const memoDataValue = canonical_VL();
|
||||
|
||||
VaultDeleteBuilder builder{
|
||||
accountValue,
|
||||
@@ -39,6 +40,7 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
|
||||
};
|
||||
|
||||
// Set optional fields
|
||||
builder.setMemoData(memoDataValue);
|
||||
|
||||
auto tx = builder.build(publicKey, secretKey);
|
||||
|
||||
@@ -62,6 +64,14 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
|
||||
}
|
||||
|
||||
// Verify optional fields
|
||||
{
|
||||
auto const& expected = memoDataValue;
|
||||
auto const actualOpt = tx.getMemoData();
|
||||
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMemoData should be present";
|
||||
expectEqualField(expected, *actualOpt, "sfMemoData");
|
||||
EXPECT_TRUE(tx.hasMemoData());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
|
||||
@@ -79,6 +89,7 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
|
||||
|
||||
// Transaction-specific field values
|
||||
auto const vaultIDValue = canonical_UINT256();
|
||||
auto const memoDataValue = canonical_VL();
|
||||
|
||||
// Build an initial transaction
|
||||
VaultDeleteBuilder initialBuilder{
|
||||
@@ -88,6 +99,7 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
|
||||
feeValue
|
||||
};
|
||||
|
||||
initialBuilder.setMemoData(memoDataValue);
|
||||
|
||||
auto initialTx = initialBuilder.build(publicKey, secretKey);
|
||||
|
||||
@@ -112,6 +124,13 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
|
||||
}
|
||||
|
||||
// Verify optional fields
|
||||
{
|
||||
auto const& expected = memoDataValue;
|
||||
auto const actualOpt = rebuiltTx.getMemoData();
|
||||
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMemoData should be present";
|
||||
expectEqualField(expected, *actualOpt, "sfMemoData");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 3) Verify wrapper throws when constructed from wrong transaction type.
|
||||
@@ -142,5 +161,35 @@ TEST(TransactionsVaultDeleteTests, BuilderThrowsOnWrongTxType)
|
||||
EXPECT_THROW(VaultDeleteBuilder{wrongTx.getSTTx()}, std::runtime_error);
|
||||
}
|
||||
|
||||
// 5) Build with only required fields and verify optional fields return nullopt.
|
||||
TEST(TransactionsVaultDeleteTests, OptionalFieldsReturnNullopt)
|
||||
{
|
||||
// Generate a deterministic keypair for signing
|
||||
auto const [publicKey, secretKey] =
|
||||
generateKeyPair(KeyType::secp256k1, generateSeed("testVaultDeleteNullopt"));
|
||||
|
||||
// Common transaction fields
|
||||
auto const accountValue = calcAccountID(publicKey);
|
||||
std::uint32_t const sequenceValue = 3;
|
||||
auto const feeValue = canonical_AMOUNT();
|
||||
|
||||
// Transaction-specific required field values
|
||||
auto const vaultIDValue = canonical_UINT256();
|
||||
|
||||
VaultDeleteBuilder builder{
|
||||
accountValue,
|
||||
vaultIDValue,
|
||||
sequenceValue,
|
||||
feeValue
|
||||
};
|
||||
|
||||
// Do NOT set optional fields
|
||||
|
||||
auto tx = builder.build(publicKey, secretKey);
|
||||
|
||||
// Verify optional fields are not present
|
||||
EXPECT_FALSE(tx.hasMemoData());
|
||||
EXPECT_FALSE(tx.getMemoData().has_value());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user