mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-26 23:19:07 +00:00
Merge remote-tracking branch 'Transia-RnD-rippled/dangell7/vault-accrual' into develop
# Conflicts: # include/xrpl/tx/invariants/InvariantCheck.h
This commit is contained in:
@@ -101,6 +101,24 @@ static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60;
|
||||
Number
|
||||
loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval);
|
||||
|
||||
/**
|
||||
* Assets a loan earns per second at this principal outstanding.
|
||||
*
|
||||
* Equation (27) of XLS-66 is linear in elapsed time, and sfPaymentInterval
|
||||
* cancels out of it, so a loan's accrual rate depends only on its principal
|
||||
* and interest rate. Principal is flat between payments, which makes the rate
|
||||
* piecewise-constant with breakpoints exactly at the events that update it —
|
||||
* summing it across a vault's loans is therefore exact, not an approximation.
|
||||
*/
|
||||
inline Number
|
||||
loanAccrualRate(Number const& principalOutstanding, TenthBips32 interestRate)
|
||||
{
|
||||
if (interestRate == TenthBips32{0} || principalOutstanding <= Number{})
|
||||
return Number{};
|
||||
|
||||
return tenthBipsOfValue(principalOutstanding, interestRate) / Number{kSecondsInYear};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the periodic payment is always rounded consistently
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
@@ -16,20 +17,111 @@
|
||||
namespace xrpl {
|
||||
|
||||
class STTx;
|
||||
/**
|
||||
* Interest the vault's loans have earned since sfLastAccrualTime, capped by
|
||||
* the sfUnearnedInterest budget still left to recognize.
|
||||
*
|
||||
* sfAssetsTotal is only credited when a loan event settles the vault, so
|
||||
* between those events it lags by this amount. Adding it back at read time
|
||||
* recognizes interest continuously as it is earned, without writing to
|
||||
* sfAssetsTotal outside the loan transactions.
|
||||
*
|
||||
* Returns zero when nothing is accruing — no rate, no budget left, or a vault
|
||||
* created before featureLendingProtocolV1_1.
|
||||
*/
|
||||
[[nodiscard]] Number
|
||||
vaultAccruedInterest(ReadView const& view, SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* Whether a rolling vault is inside a dealing window at this close time.
|
||||
*
|
||||
* A rolling vault deals in [SubscriptionDate + k * DealingInterval,
|
||||
* SubscriptionDate + k * DealingInterval + DealingWindow) for integer k >= 0.
|
||||
* Returns true for any vault that is not rolling, which has no windows to be
|
||||
* outside of, and false before the first window opens.
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
inDealingWindow(ReadView const& view, SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* End of the dealing window containing this close time.
|
||||
*
|
||||
* Only meaningful when inDealingWindow is true for a rolling vault; it is the
|
||||
* sfStruckUntil written when a window's price is struck.
|
||||
*/
|
||||
[[nodiscard]] std::uint32_t
|
||||
dealingWindowEnd(ReadView const& view, SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* The price every deal in the current window converts at, in vault asset per
|
||||
* share, or nullopt when no struck price governs this ledger.
|
||||
*
|
||||
* Returns a price only for a rolling vault inside a window whose sfStruckUntil
|
||||
* matches that window's end. Accrual continues underneath it: the dealing price
|
||||
* is frozen for the window, the accounting is not.
|
||||
*/
|
||||
/**
|
||||
* The interest recognition method of a vault.
|
||||
*
|
||||
* Returns sfAccountingMethod where it is present. A vault created before
|
||||
* featureVaultContinuousAccrual carries no such field, so the method is derived
|
||||
* from its schema version instead: CashBasis recognizes interest as it is
|
||||
* collected, and anything older is Legacy, which recognizes a loan's whole-life
|
||||
* interest at origination. Every vault that exists today therefore resolves
|
||||
* without being touched.
|
||||
*/
|
||||
[[nodiscard]] std::uint8_t
|
||||
getAccountingMethod(SLE::const_ref vault);
|
||||
|
||||
[[nodiscard]] std::optional<Number>
|
||||
struckPriceInForce(ReadView const& view, SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* Strike the price for the current window if it has not been struck yet.
|
||||
*
|
||||
* Called by the first deposit or withdrawal of a window. Does nothing for a
|
||||
* vault that is not rolling, outside a window, or where this window's price is
|
||||
* already struck, so it is safe to call unconditionally.
|
||||
*/
|
||||
void
|
||||
strikeWindowPrice(ApplyView& view, SLE::ref vault, SLE::const_ref issuance);
|
||||
|
||||
/**
|
||||
* Credit interest earned since the last settlement into sfAssetsTotal, draw
|
||||
* it out of the sfUnearnedInterest budget, and stamp the current close time.
|
||||
*
|
||||
* Must be called before adjusting sfAccrualRate, so the elapsed period is
|
||||
* charged at the rate that was in effect over it. Only the ttLOAN_*
|
||||
* transactions may call this: ValidVault requires sfAssetsTotal to move with
|
||||
* the vault balance on deposit and withdraw, which settling would violate.
|
||||
* Pricing does not need it — the conversion helpers add elapsed interest
|
||||
* themselves.
|
||||
*/
|
||||
void
|
||||
accrueVault(ApplyView& view, SLE::ref vault);
|
||||
|
||||
/**
|
||||
* From the perspective of a vault, return the number of shares to give
|
||||
* depositor when they offer a fixed amount of assets. Note, since shares are
|
||||
* MPT, this number is integral and always truncated in this calculation.
|
||||
*
|
||||
* @param vault The vault SLE.
|
||||
* @param issuance The MPTokenIssuance SLE for the vault's shares.
|
||||
* @param assets The amount of assets to convert.
|
||||
*
|
||||
* @return The number of shares, or nullopt on error.
|
||||
* /**
|
||||
* * From the perspective of a vault, return the number of shares to give
|
||||
* * depositor when they offer a fixed amount of assets. Note, since shares are
|
||||
* * MPT, this number is integral and always truncated in this calculation.
|
||||
* *
|
||||
* * @param vault The vault SLE.
|
||||
* * @param issuance The MPTokenIssuance SLE for the vault's shares.
|
||||
* * @param assets The amount of assets to convert.
|
||||
* *
|
||||
* * @return The number of shares, or nullopt on error.
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets);
|
||||
assetsToSharesDeposit(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& assets);
|
||||
|
||||
/**
|
||||
* From the perspective of a vault, return the number of assets to take from
|
||||
@@ -43,7 +135,11 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
|
||||
* @return The number of assets, or nullopt on error.
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
|
||||
sharesToAssetsDeposit(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares);
|
||||
|
||||
/**
|
||||
* Adjusts a requested asset change (`delta`) to match the decimal scale of the
|
||||
@@ -91,11 +187,12 @@ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true };
|
||||
* unrealized loss is waived. Used by assetsToSharesWithdraw and
|
||||
* sharesToAssetsWithdraw as the numerator of the share/asset exchange rate.
|
||||
*
|
||||
* @param view The ledger view, for interest accrued since the last settlement.
|
||||
* @param vault The vault SLE.
|
||||
* @param waive Whether to skip subtracting the unrealized loss.
|
||||
*/
|
||||
[[nodiscard]] Number
|
||||
assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive);
|
||||
assetsTotalForWithdrawal(ReadView const& view, SLE::const_ref vault, WaiveUnrealizedLoss waive);
|
||||
|
||||
/**
|
||||
* Returns true if debiting `amount` from `total` (the current value of a
|
||||
@@ -131,6 +228,7 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
assetsToSharesWithdraw(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& assets,
|
||||
@@ -152,6 +250,7 @@ assetsToSharesWithdraw(
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
sharesToAssetsWithdraw(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
|
||||
@@ -312,6 +312,19 @@ constexpr std::size_t kMaxSchemaLength = 256;
|
||||
*/
|
||||
constexpr std::uint8_t kVaultStrategyFirstComeFirstServe = 1;
|
||||
|
||||
/**
|
||||
* Vault interest recognition methods.
|
||||
*
|
||||
* Legacy recognizes a loan's whole-life interest at origination and is the
|
||||
* implicit method for vaults created before featureLendingProtocolV1_1. Cash
|
||||
* recognizes interest as it is collected; accrual recognizes it continuously
|
||||
* as it is earned. Fixed at VaultCreate — changing it would reprice every
|
||||
* outstanding share in a single step.
|
||||
*/
|
||||
constexpr std::uint8_t kVaultAccountingLegacy = 0;
|
||||
constexpr std::uint8_t kVaultAccountingCash = 1;
|
||||
constexpr std::uint8_t kVaultAccountingAccrual = 2;
|
||||
|
||||
/**
|
||||
* Default IOU scale factor for a Vault
|
||||
*/
|
||||
@@ -321,7 +334,13 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6;
|
||||
* 1 IOU can be always converted to shares.
|
||||
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18
|
||||
*/
|
||||
constexpr std::uint8_t kVaultMaximumIouScale = 18;
|
||||
constexpr std::uint8_t kVaultMaximumIouScale =
|
||||
18; /** Largest deposit or redemption fee a vault may charge, in 1/10 bips.
|
||||
|
||||
Matches kMaxTransferFee: half of what is moved is the most any fee may
|
||||
retain.
|
||||
*/
|
||||
constexpr std::uint32_t kMaxVaultFee = 50'000;
|
||||
|
||||
/**
|
||||
* Vault ledger-entry schema versions. Assigned to newly created
|
||||
@@ -335,12 +354,16 @@ enum class VaultVersion : uint8_t {
|
||||
};
|
||||
|
||||
/**
|
||||
* Vault kind. Distinguishes closed-ended vaults from the default open-ended
|
||||
* kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded.
|
||||
* Vault kind. Distinguishes closed-ended and rolling vaults from the default
|
||||
* open-ended kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded.
|
||||
*
|
||||
* A rolling vault deals in a window that reopens every sfDealingInterval
|
||||
* seconds and stays open for sfDealingWindow of them.
|
||||
*/
|
||||
enum class VaultKind : std::uint8_t {
|
||||
OpenEnded = 0,
|
||||
ClosedEnded = 1,
|
||||
Rolling = 2,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,3 +154,4 @@ XRPL_FEATURE(Quantum, Supported::Yes, VoteBehavior::DefaultN
|
||||
XRPL_FEATURE(Passkey, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(MPTStructuredData, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(TokenIssuance, Supported::No, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(VaultContinuousAccrual, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
|
||||
@@ -442,6 +442,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({
|
||||
{sfIssuerEncryptedBalance, SoeOptional},
|
||||
{sfAuditorEncryptedBalance, SoeOptional},
|
||||
{sfHolderEncryptionKey, SoeOptional},
|
||||
{sfRedemptionAfter, SoeDefault},
|
||||
}))
|
||||
|
||||
/** A ledger object which tracks Oracle
|
||||
@@ -516,13 +517,24 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
|
||||
{sfAssetsAvailable, SoeDefault},
|
||||
{sfAssetsMaximum, SoeDefault},
|
||||
{sfLossUnrealized, SoeDefault},
|
||||
{sfUnearnedInterest, SoeDefault},
|
||||
{sfAccrualRate, SoeDefault},
|
||||
{sfLastAccrualTime, SoeDefault},
|
||||
{sfShareMPTID, SoeRequired},
|
||||
{sfWithdrawalPolicy, SoeRequired},
|
||||
{sfAccountingMethod, SoeDefault},
|
||||
{sfScale, SoeDefault},
|
||||
{sfLEVersion, SoeDefault},
|
||||
{sfVaultKind, SoeDefault},
|
||||
{sfSubscriptionDate, SoeOptional},
|
||||
{sfRedemptionDate, SoeOptional},
|
||||
{sfDealingInterval, SoeDefault},
|
||||
{sfDealingWindow, SoeDefault},
|
||||
{sfStruckPrice, SoeDefault},
|
||||
{sfStruckUntil, SoeDefault},
|
||||
{sfDepositFee, SoeDefault},
|
||||
{sfRedemptionFee, SoeDefault},
|
||||
{sfRedemptionPeriod, SoeDefault},
|
||||
// no SharesTotal ever (use MPTIssuance.sfOutstandingAmount)
|
||||
// no PermissionedDomainID ever (use MPTIssuance.sfDomainID)
|
||||
}))
|
||||
|
||||
@@ -483,3 +483,15 @@ TYPED_SFIELD(sfTokenScale, UINT8, 7)
|
||||
TYPED_SFIELD(sfTokenIssuanceID, UINT256, 44)
|
||||
TYPED_SFIELD(sfIssuedAmount, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfCurrency, CURRENCY, 3)
|
||||
TYPED_SFIELD(sfAccountingMethod, UINT8, 23)
|
||||
TYPED_SFIELD(sfLastAccrualTime, UINT32, 88)
|
||||
TYPED_SFIELD(sfDealingInterval, UINT32, 89)
|
||||
TYPED_SFIELD(sfDealingWindow, UINT32, 90)
|
||||
TYPED_SFIELD(sfStruckUntil, UINT32, 91)
|
||||
TYPED_SFIELD(sfDepositFee, UINT32, 92)
|
||||
TYPED_SFIELD(sfRedemptionFee, UINT32, 93)
|
||||
TYPED_SFIELD(sfRedemptionPeriod, UINT32, 94)
|
||||
TYPED_SFIELD(sfRedemptionAfter, UINT32, 95)
|
||||
TYPED_SFIELD(sfUnearnedInterest, NUMBER, 19, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfAccrualRate, NUMBER, 20, SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfStruckPrice, NUMBER, 21, SField::kSmdDefault)
|
||||
|
||||
@@ -802,6 +802,12 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate,
|
||||
{sfVaultKind, SoeOptional},
|
||||
{sfSubscriptionDate, SoeOptional},
|
||||
{sfRedemptionDate, SoeOptional},
|
||||
{sfDealingInterval, SoeOptional},
|
||||
{sfDealingWindow, SoeOptional},
|
||||
{sfDepositFee, SoeOptional},
|
||||
{sfRedemptionFee, SoeOptional},
|
||||
{sfRedemptionPeriod, SoeOptional},
|
||||
{sfAccountingMethod, SoeOptional},
|
||||
}))
|
||||
|
||||
/** This transaction updates a single asset vault. */
|
||||
@@ -818,6 +824,9 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet,
|
||||
{sfAssetsMaximum, SoeOptional},
|
||||
{sfDomainID, SoeOptional},
|
||||
{sfData, SoeOptional},
|
||||
{sfDepositFee, SoeOptional},
|
||||
{sfRedemptionFee, SoeOptional},
|
||||
{sfRedemptionPeriod, SoeOptional},
|
||||
}))
|
||||
|
||||
/** This transaction deletes a single asset vault. */
|
||||
|
||||
@@ -242,6 +242,78 @@ public:
|
||||
return this->sle_->isFieldPresent(sfLossUnrealized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfUnearnedInterest (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_NUMBER::type::value_type>
|
||||
getUnearnedInterest() const
|
||||
{
|
||||
if (hasUnearnedInterest())
|
||||
return this->sle_->at(sfUnearnedInterest);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfUnearnedInterest is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasUnearnedInterest() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfUnearnedInterest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfAccrualRate (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_NUMBER::type::value_type>
|
||||
getAccrualRate() const
|
||||
{
|
||||
if (hasAccrualRate())
|
||||
return this->sle_->at(sfAccrualRate);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfAccrualRate is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasAccrualRate() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfAccrualRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfLastAccrualTime (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_UINT32::type::value_type>
|
||||
getLastAccrualTime() const
|
||||
{
|
||||
if (hasLastAccrualTime())
|
||||
return this->sle_->at(sfLastAccrualTime);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfLastAccrualTime is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasLastAccrualTime() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfLastAccrualTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfShareMPTID (SoeRequired)
|
||||
* @return The field value.
|
||||
@@ -264,6 +336,30 @@ public:
|
||||
return this->sle_->at(sfWithdrawalPolicy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfAccountingMethod (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_UINT8::type::value_type>
|
||||
getAccountingMethod() const
|
||||
{
|
||||
if (hasAccountingMethod())
|
||||
return this->sle_->at(sfAccountingMethod);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfAccountingMethod is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasAccountingMethod() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfAccountingMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfScale (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
@@ -571,6 +667,39 @@ public:
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfUnearnedInterest (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultBuilder&
|
||||
setUnearnedInterest(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
|
||||
{
|
||||
object_[sfUnearnedInterest] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfAccrualRate (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultBuilder&
|
||||
setAccrualRate(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
|
||||
{
|
||||
object_[sfAccrualRate] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfLastAccrualTime (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultBuilder&
|
||||
setLastAccrualTime(std::decay_t<typename SF_UINT32::type::value_type> const& value)
|
||||
{
|
||||
object_[sfLastAccrualTime] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfShareMPTID (SoeRequired)
|
||||
* @return Reference to this builder for method chaining.
|
||||
@@ -593,6 +722,17 @@ public:
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfAccountingMethod (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
VaultBuilder&
|
||||
setAccountingMethod(std::decay_t<typename SF_UINT8::type::value_type> const& value)
|
||||
{
|
||||
object_[sfAccountingMethod] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfScale (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <xrpl/tx/invariants/PermissionedDomainInvariant.h>
|
||||
#include <xrpl/tx/invariants/SponsorshipInvariant.h>
|
||||
#include <xrpl/tx/invariants/TokenIssuanceInvariant.h>
|
||||
#include <xrpl/tx/invariants/VaultAccrualInvariant.h>
|
||||
#include <xrpl/tx/invariants/VaultInvariant.h>
|
||||
|
||||
#include <cstdint>
|
||||
@@ -481,6 +482,7 @@ using InvariantChecks = std::tuple<
|
||||
ValidLoanBroker,
|
||||
ValidLoan,
|
||||
ValidVault,
|
||||
ValidVaultAccrual,
|
||||
ValidConfidentialMPToken,
|
||||
ValidMPTBalanceChanges,
|
||||
ValidAmounts,
|
||||
|
||||
57
include/xrpl/tx/invariants/VaultAccrualInvariant.h
Normal file
57
include/xrpl/tx/invariants/VaultAccrualInvariant.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
/**
|
||||
* @brief Invariant: the continuous-accrual state of a vault moves only where it
|
||||
* is allowed to.
|
||||
*
|
||||
* Enforces XLS Vault Continuous Accrual section 7 for every Vault entry the
|
||||
* transaction touches:
|
||||
*
|
||||
* 1. sfAccrualRate and sfUnearnedInterest are never negative.
|
||||
* 2. sfLastAccrualTime never decreases, and changes only in a ttLOAN_*
|
||||
* transaction, which is where the vault settles.
|
||||
* 4. VaultDeposit, VaultWithdraw and VaultClawback leave sfAccrualRate,
|
||||
* sfUnearnedInterest and sfLastAccrualTime untouched: they price against the
|
||||
* accrued value but never settle it.
|
||||
* 6. sfStruckPrice changes at most once per window and only in a dealing
|
||||
* transaction, and sfStruckUntil only ever moves forward.
|
||||
*
|
||||
* Numbers 3 and 5 of that section need the loan book and the fee split
|
||||
* respectively, and are not enforced here.
|
||||
*/
|
||||
class ValidVaultAccrual
|
||||
{
|
||||
struct Accrual final
|
||||
{
|
||||
Number accrualRate = Number{};
|
||||
Number unearnedInterest = Number{};
|
||||
std::uint32_t lastAccrualTime = 0;
|
||||
Number struckPrice = Number{};
|
||||
std::uint32_t struckUntil = 0;
|
||||
};
|
||||
|
||||
std::vector<Accrual> before_;
|
||||
std::vector<Accrual> after_;
|
||||
|
||||
public:
|
||||
void
|
||||
visitEntry(bool, SLE::const_ref, SLE::const_ref);
|
||||
|
||||
bool
|
||||
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
|
||||
};
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -286,9 +286,48 @@ loanOriginationExceedsVaultMaximum(
|
||||
return accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue);
|
||||
}
|
||||
|
||||
namespace continuous_accrual {
|
||||
|
||||
/*
|
||||
* Under continuous accrual the vault has recognized this loan's interest only
|
||||
* as far as the clock has reached, so the paper loss is the principal plus that
|
||||
* much interest, and neither of the other two formulas gives it: cash basis
|
||||
* books principal alone and leaves recognized interest inside NAV, while Legacy
|
||||
* books the loan's whole remaining interest, which was never recognized.
|
||||
*
|
||||
* A loan can only be impaired once it is late, and lateness means the period has
|
||||
* fully elapsed, so the recognized amount has already saturated at the period's
|
||||
* whole schedule. That makes the exposure a function of the loan alone, and
|
||||
* identical whether it is being booked on impair or reversed on unimpair.
|
||||
*/
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
|
||||
{
|
||||
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
|
||||
Number const rate = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate);
|
||||
std::uint32_t const interval = loanSle->at(sfPaymentInterval);
|
||||
|
||||
// Round the interest term to the scale the vault stores its loss at, so the
|
||||
// amount booked on impair is the amount reversed on unimpair. The other two
|
||||
// formulas return the principal alone, which is already representable.
|
||||
Asset const vaultAsset = vaultSle->at(sfAsset);
|
||||
Number const recognized = roundToAsset(
|
||||
vaultAsset,
|
||||
rate * Number{interval},
|
||||
getAssetsTotalScale(vaultSle),
|
||||
Number::RoundingMode::Downward);
|
||||
|
||||
return Number{loanSle->at(sfPrincipalOutstanding)} + recognized;
|
||||
}
|
||||
|
||||
} // namespace continuous_accrual
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle)
|
||||
{
|
||||
if (getAccountingMethod(vaultSle) == kVaultAccountingAccrual)
|
||||
return continuous_accrual::loanVaultExposure(vaultSle, loanSle);
|
||||
|
||||
return cashBasisEnabled(vaultSle) ? cash_basis::loanVaultExposure(loanSle)
|
||||
: accrual::loanVaultExposure(loanSle);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/CredentialHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
@@ -24,8 +25,72 @@
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
[[nodiscard]] Number
|
||||
vaultAccruedInterest(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
Number const unearned = vault->at(sfUnearnedInterest);
|
||||
if (unearned <= Number{})
|
||||
return Number{};
|
||||
|
||||
Number const rate = vault->at(sfAccrualRate);
|
||||
if (rate <= Number{})
|
||||
return Number{};
|
||||
|
||||
// A rate that was never stamped has no measurable elapsed period; accruing
|
||||
// from the epoch would recognize the whole budget at once.
|
||||
std::uint32_t const stamped = vault->at(sfLastAccrualTime);
|
||||
if (stamped == 0)
|
||||
return Number{};
|
||||
|
||||
auto const now = view.parentCloseTime().time_since_epoch().count();
|
||||
if (now <= stamped)
|
||||
return Number{};
|
||||
|
||||
// Round down: never recognize more than certainly earned.
|
||||
NumberRoundModeGuard const guard(Number::RoundingMode::Downward);
|
||||
Number const earned = rate * Number{now - stamped};
|
||||
return earned >= unearned ? unearned : earned;
|
||||
}
|
||||
|
||||
void
|
||||
accrueVault(ApplyView& view, SLE::ref vault)
|
||||
{
|
||||
Number const earned = vaultAccruedInterest(view, vault);
|
||||
if (earned > Number{})
|
||||
{
|
||||
vault->at(sfAssetsTotal) += earned;
|
||||
vault->at(sfUnearnedInterest) -= earned;
|
||||
}
|
||||
vault->at(sfLastAccrualTime) = view.parentCloseTime().time_since_epoch().count();
|
||||
}
|
||||
|
||||
/* The vault's assets including interest earned since the last settlement.
|
||||
*
|
||||
* sfAssetsTotal only holds interest that has been recognized, so between loan
|
||||
* events it lags by the amount accrued since sfLastAccrualTime. Pricing adds
|
||||
* that back rather than writing it, which keeps sfAssetsTotal equal to the
|
||||
* vault's cash-plus-receivables and leaves the deposit/withdraw invariants
|
||||
* (which require sfAssetsTotal to move only with the vault balance) intact.
|
||||
*
|
||||
* Before featureLendingProtocolV1_1 the whole of a loan's interest is
|
||||
* recognized at origination, so sfAssetsTotal stands alone.
|
||||
*/
|
||||
static Number
|
||||
netAssetsTotal(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
Number const assetTotal = vault->at(sfAssetsTotal);
|
||||
if (!view.rules().enabled(featureLendingProtocolV1_1))
|
||||
return assetTotal;
|
||||
|
||||
return assetTotal + vaultAccruedInterest(view, vault);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets)
|
||||
assetsToSharesDeposit(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& assets)
|
||||
{
|
||||
XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesDeposit : non-negative assets");
|
||||
XRPL_ASSERT(
|
||||
@@ -34,9 +99,13 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
|
||||
if (assets.negative() || assets.asset() != vault->at(sfAsset))
|
||||
return std::nullopt; // LCOV_EXCL_LINE
|
||||
|
||||
Number const assetTotal = vault->at(sfAssetsTotal);
|
||||
// Inside a struck window every deal converts at the one price.
|
||||
if (auto const struck = struckPriceInForce(view, vault))
|
||||
return STAmount{vault->at(sfShareMPTID), (Number{assets} / *struck).truncate()};
|
||||
|
||||
Number const assetTotal = netAssetsTotal(view, vault);
|
||||
STAmount shares{vault->at(sfShareMPTID)};
|
||||
if (assetTotal == 0)
|
||||
if (assetTotal <= Number{})
|
||||
{
|
||||
return STAmount{
|
||||
shares.asset(),
|
||||
@@ -49,7 +118,11 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
|
||||
sharesToAssetsDeposit(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares)
|
||||
{
|
||||
XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsDeposit : non-negative shares");
|
||||
XRPL_ASSERT(
|
||||
@@ -58,9 +131,12 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
|
||||
if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
|
||||
return std::nullopt; // LCOV_EXCL_LINE
|
||||
|
||||
Number const assetTotal = vault->at(sfAssetsTotal);
|
||||
if (auto const struck = struckPriceInForce(view, vault))
|
||||
return STAmount{vault->at(sfAsset), Number{shares} * *struck};
|
||||
|
||||
Number const assetTotal = netAssetsTotal(view, vault);
|
||||
STAmount assets{vault->at(sfAsset)};
|
||||
if (assetTotal == 0)
|
||||
if (assetTotal <= Number{})
|
||||
{
|
||||
return STAmount{
|
||||
assets.asset(), shares.mantissa(), shares.exponent() - vault->at(sfScale), false};
|
||||
@@ -131,9 +207,9 @@ clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta)
|
||||
}
|
||||
|
||||
[[nodiscard]] Number
|
||||
assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
|
||||
assetsTotalForWithdrawal(ReadView const& view, SLE::const_ref vault, WaiveUnrealizedLoss waive)
|
||||
{
|
||||
Number assetTotal = vault->at(sfAssetsTotal);
|
||||
Number assetTotal = netAssetsTotal(view, vault);
|
||||
if (waive == WaiveUnrealizedLoss::No)
|
||||
assetTotal -= vault->at(sfLossUnrealized);
|
||||
return assetTotal;
|
||||
@@ -149,6 +225,7 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
assetsToSharesWithdraw(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& assets,
|
||||
@@ -162,9 +239,17 @@ assetsToSharesWithdraw(
|
||||
if (assets.negative() || assets.asset() != vault->at(sfAsset))
|
||||
return std::nullopt; // LCOV_EXCL_LINE
|
||||
|
||||
Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
|
||||
if (auto const struck = struckPriceInForce(view, vault))
|
||||
{
|
||||
Number struckShares = Number{assets} / *struck;
|
||||
if (truncate == TruncateShares::Yes)
|
||||
struckShares = struckShares.truncate();
|
||||
return STAmount{vault->at(sfShareMPTID), struckShares};
|
||||
}
|
||||
|
||||
Number const assetTotal = assetsTotalForWithdrawal(view, vault, waive);
|
||||
STAmount shares{vault->at(sfShareMPTID)};
|
||||
if (assetTotal == 0)
|
||||
if (assetTotal <= Number{})
|
||||
return shares;
|
||||
Number const shareTotal = issuance->at(sfOutstandingAmount);
|
||||
Number result = (shareTotal * assets) / assetTotal;
|
||||
@@ -176,6 +261,7 @@ assetsToSharesWithdraw(
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
sharesToAssetsWithdraw(
|
||||
ReadView const& view,
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
@@ -188,9 +274,12 @@ sharesToAssetsWithdraw(
|
||||
if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
|
||||
return std::nullopt; // LCOV_EXCL_LINE
|
||||
|
||||
Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
|
||||
if (auto const struck = struckPriceInForce(view, vault))
|
||||
return STAmount{vault->at(sfAsset), Number{shares} * *struck};
|
||||
|
||||
Number const assetTotal = assetsTotalForWithdrawal(view, vault, waive);
|
||||
STAmount assets{vault->at(sfAsset)};
|
||||
if (assetTotal == 0)
|
||||
if (assetTotal <= Number{})
|
||||
return assets;
|
||||
Number const shareTotal = issuance->at(sfOutstandingAmount);
|
||||
assets = (assetTotal * shares) / shareTotal;
|
||||
@@ -242,11 +331,131 @@ decodeVaultKind(std::optional<std::uint8_t> vaultKind)
|
||||
{
|
||||
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
|
||||
return VaultKind::ClosedEnded;
|
||||
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::Rolling))
|
||||
return VaultKind::Rolling;
|
||||
return VaultKind::OpenEnded;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
* Seconds from the first window's open to this close time, or nullopt when the
|
||||
* vault is not rolling or the first window has not opened yet.
|
||||
*/
|
||||
[[nodiscard]] std::optional<std::uint64_t>
|
||||
sinceFirstWindow(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling)
|
||||
return std::nullopt;
|
||||
|
||||
auto const start = vault->at(~sfSubscriptionDate);
|
||||
auto const interval = vault->at(~sfDealingInterval);
|
||||
if (!start || !interval || *interval == 0)
|
||||
return std::nullopt;
|
||||
|
||||
auto const now = view.header().parentCloseTime.time_since_epoch().count();
|
||||
if (now < *start)
|
||||
return std::nullopt;
|
||||
|
||||
return static_cast<std::uint64_t>(now) - static_cast<std::uint64_t>(*start);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
[[nodiscard]] bool
|
||||
inDealingWindow(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::inDealingWindow : valid Vault sle");
|
||||
|
||||
if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling)
|
||||
return true;
|
||||
|
||||
auto const elapsed = sinceFirstWindow(view, vault);
|
||||
if (!elapsed)
|
||||
return false;
|
||||
|
||||
return *elapsed % vault->at(sfDealingInterval) < vault->at(sfDealingWindow);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t
|
||||
dealingWindowEnd(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::dealingWindowEnd : valid Vault sle");
|
||||
|
||||
auto const elapsed = sinceFirstWindow(view, vault);
|
||||
if (!elapsed)
|
||||
return 0;
|
||||
|
||||
std::uint64_t const interval = vault->at(sfDealingInterval);
|
||||
std::uint64_t const opened = *elapsed - (*elapsed % interval);
|
||||
return static_cast<std::uint32_t>(
|
||||
vault->at(sfSubscriptionDate) + opened + vault->at(sfDealingWindow));
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint8_t
|
||||
getAccountingMethod(SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT, "xrpl::getAccountingMethod : valid Vault sle");
|
||||
|
||||
if (auto const method = vault->at(~sfAccountingMethod))
|
||||
return *method;
|
||||
|
||||
return vault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)
|
||||
? kVaultAccountingCash
|
||||
: kVaultAccountingLegacy;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<Number>
|
||||
struckPriceInForce(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::struckPriceInForce : valid Vault sle");
|
||||
|
||||
if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling)
|
||||
return std::nullopt;
|
||||
if (!inDealingWindow(view, vault))
|
||||
return std::nullopt;
|
||||
|
||||
// A stamp from an earlier window does not govern this one.
|
||||
if (vault->at(sfStruckUntil) != dealingWindowEnd(view, vault))
|
||||
return std::nullopt;
|
||||
|
||||
Number const price = vault->at(sfStruckPrice);
|
||||
if (price <= Number{})
|
||||
return std::nullopt;
|
||||
|
||||
return price;
|
||||
}
|
||||
|
||||
void
|
||||
strikeWindowPrice(ApplyView& view, SLE::ref vault, SLE::const_ref issuance)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::strikeWindowPrice : valid Vault sle");
|
||||
|
||||
if (decodeVaultKind(vault->at(~sfVaultKind)) != VaultKind::Rolling)
|
||||
return;
|
||||
if (!inDealingWindow(view, vault))
|
||||
return;
|
||||
|
||||
auto const windowEnd = dealingWindowEnd(view, vault);
|
||||
if (vault->at(sfStruckUntil) == windowEnd)
|
||||
return; // already struck for this window
|
||||
|
||||
Number const shareTotal = issuance->at(sfOutstandingAmount);
|
||||
if (shareTotal <= Number{})
|
||||
return; // no shares yet, so nothing to price against
|
||||
|
||||
Number const assetTotal = netAssetsTotal(view, vault) - vault->at(sfLossUnrealized);
|
||||
if (assetTotal <= Number{})
|
||||
return;
|
||||
|
||||
vault->at(sfStruckPrice) = assetTotal / shareTotal;
|
||||
vault->at(sfStruckUntil) = windowEnd;
|
||||
view.update(vault);
|
||||
}
|
||||
|
||||
[[nodiscard]] VaultKind
|
||||
getVaultKind(SLE::const_ref vault)
|
||||
{
|
||||
@@ -267,7 +476,8 @@ isValidVaultKind(STTx const& tx)
|
||||
if (!kindField)
|
||||
return true;
|
||||
return *kindField == std::to_underlying(VaultKind::OpenEnded) ||
|
||||
*kindField == std::to_underlying(VaultKind::ClosedEnded);
|
||||
*kindField == std::to_underlying(VaultKind::ClosedEnded) ||
|
||||
*kindField == std::to_underlying(VaultKind::Rolling);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool
|
||||
|
||||
152
src/libxrpl/tx/invariants/VaultAccrualInvariant.cpp
Normal file
152
src/libxrpl/tx/invariants/VaultAccrualInvariant.cpp
Normal file
@@ -0,0 +1,152 @@
|
||||
#include <xrpl/tx/invariants/VaultAccrualInvariant.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] bool
|
||||
isLoanTransaction(TxType const txType)
|
||||
{
|
||||
switch (txType)
|
||||
{
|
||||
case ttLOAN_SET:
|
||||
case ttLOAN_PAY:
|
||||
case ttLOAN_MANAGE:
|
||||
case ttLOAN_DELETE:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool
|
||||
isDealingTransaction(TxType const txType)
|
||||
{
|
||||
switch (txType)
|
||||
{
|
||||
case ttVAULT_DEPOSIT:
|
||||
case ttVAULT_WITHDRAW:
|
||||
case ttVAULT_CLAWBACK:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void
|
||||
ValidVaultAccrual::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
|
||||
{
|
||||
auto const read = [](SLE::const_ref sle) {
|
||||
return Accrual{
|
||||
.accrualRate = sle->at(sfAccrualRate),
|
||||
.unearnedInterest = sle->at(sfUnearnedInterest),
|
||||
.lastAccrualTime = sle->at(sfLastAccrualTime),
|
||||
.struckPrice = sle->at(sfStruckPrice),
|
||||
.struckUntil = sle->at(sfStruckUntil)};
|
||||
};
|
||||
|
||||
if (before && before->getType() == ltVAULT)
|
||||
before_.push_back(read(before));
|
||||
|
||||
// A deleted vault constrains nothing about the state it no longer has.
|
||||
if (!isDelete && after && after->getType() == ltVAULT)
|
||||
after_.push_back(read(after));
|
||||
}
|
||||
|
||||
bool
|
||||
ValidVaultAccrual::finalize(
|
||||
STTx const& tx,
|
||||
TER const result,
|
||||
XRPAmount const,
|
||||
ReadView const&,
|
||||
beast::Journal const& j)
|
||||
{
|
||||
if (!isTesSuccess(result))
|
||||
return true;
|
||||
|
||||
for (auto const& accrual : after_)
|
||||
{
|
||||
if (accrual.accrualRate < Number{})
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault accrual rate is negative";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (accrual.unearnedInterest < Number{})
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault unearned interest is negative";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Pair the states only when the transaction touched a single vault, which is
|
||||
// every transaction that can move these fields.
|
||||
if (before_.size() != 1 || after_.size() != 1)
|
||||
return true;
|
||||
|
||||
auto const& was = before_.front();
|
||||
auto const& is = after_.front();
|
||||
auto const txType = tx.getTxnType();
|
||||
|
||||
if (is.lastAccrualTime < was.lastAccrualTime)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault last accrual time moved backwards";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is.lastAccrualTime != was.lastAccrualTime && !isLoanTransaction(txType))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault settled outside a loan transaction";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDealingTransaction(txType) &&
|
||||
(is.accrualRate != was.accrualRate || is.unearnedInterest != was.unearnedInterest ||
|
||||
is.lastAccrualTime != was.lastAccrualTime))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: dealing transaction changed vault accrual state";
|
||||
return false;
|
||||
}
|
||||
|
||||
// A window's price, once struck, stands until the window it was struck for
|
||||
// has passed. Only a deal inside a window may strike one.
|
||||
if (is.struckUntil < was.struckUntil)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault struck window moved backwards";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool const struck = is.struckPrice != was.struckPrice || is.struckUntil != was.struckUntil;
|
||||
if (struck && !isDealingTransaction(txType))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault price struck outside a dealing transaction";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Striking again within the same window would let a later deal reprice an
|
||||
// earlier one, which is the timing problem the struck price exists to close.
|
||||
if (is.struckPrice != was.struckPrice && is.struckUntil == was.struckUntil)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: vault price struck twice in one window";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -153,7 +153,14 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
|
||||
// stays unrestricted so existing open-ended flows keep working;
|
||||
// the constraint is enforced here, at the point where the vault
|
||||
// is first bound to the lending protocol.
|
||||
if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
|
||||
// LP V1.2 lifts this for accrual vaults: a continuous price has no step
|
||||
// to front-run, so an open-ended or rolling vault is safe to deal on
|
||||
// while interest is being earned. Cash basis keeps the restriction,
|
||||
// because its price still moves in a step at each payment.
|
||||
bool const accrualPriced = ctx.view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
getAccountingMethod(sleVault) == kVaultAccountingAccrual;
|
||||
|
||||
if (ctx.view.rules().enabled(featureLendingProtocolV1_1) && !accrualPriced &&
|
||||
getVaultKind(sleVault) != VaultKind::ClosedEnded)
|
||||
{
|
||||
JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault.";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -299,6 +300,22 @@ LoanManage::impairLoan(
|
||||
return tecTOO_SOON;
|
||||
}
|
||||
|
||||
// Settle before the loss is measured, so the interest the clock earned over
|
||||
// the period is already in AssetsTotal and the paper loss can cover it, then
|
||||
// stop this loan accruing. Continuing to accrue on a loan that may never pay
|
||||
// is the Legacy defect in miniature.
|
||||
bool const continuousAccrual = view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
getAccountingMethod(vaultSle) == kVaultAccountingAccrual;
|
||||
if (continuousAccrual)
|
||||
{
|
||||
accrueVault(view, vaultSle);
|
||||
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
|
||||
Number const loanRate = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate);
|
||||
auto rateProxy = vaultSle->at(sfAccrualRate);
|
||||
Number const remaining = *rateProxy - loanRate;
|
||||
rateProxy = remaining > Number{} ? remaining : Number{};
|
||||
}
|
||||
|
||||
Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
|
||||
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
@@ -316,6 +333,7 @@ LoanManage::impairLoan(
|
||||
JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
|
||||
return tecLIMIT_EXCEEDED;
|
||||
}
|
||||
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the Loan object
|
||||
@@ -361,6 +379,17 @@ LoanManage::unimpairLoan(
|
||||
// Reverse the "paper loss"
|
||||
adjustImpreciseNumber(vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale);
|
||||
|
||||
// Settle while this loan is still excluded, so the impaired interval earns
|
||||
// nothing, then take its rate back on.
|
||||
if (view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
getAccountingMethod(vaultSle) == kVaultAccountingAccrual)
|
||||
{
|
||||
accrueVault(view, vaultSle);
|
||||
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
|
||||
vaultSle->at(sfAccrualRate) +=
|
||||
loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate);
|
||||
}
|
||||
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the Loan object
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
@@ -413,6 +414,32 @@ LoanPay::doApply()
|
||||
return LoanPaymentType::Regular;
|
||||
}();
|
||||
|
||||
// An accrual vault has already recognized part of this period's interest by
|
||||
// the clock. Capture what the clock earned, and the period's whole schedule,
|
||||
// before loanMakePayment moves the loan on.
|
||||
bool const accrualVault = view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
getAccountingMethod(vaultSle) == kVaultAccountingAccrual;
|
||||
Number rateBefore{};
|
||||
Number periodScheduled{};
|
||||
Number recognizedPeriod{};
|
||||
if (accrualVault)
|
||||
{
|
||||
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
|
||||
rateBefore = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate);
|
||||
|
||||
std::uint32_t const interval = loanSle->at(sfPaymentInterval);
|
||||
periodScheduled = rateBefore * Number{interval};
|
||||
|
||||
std::uint32_t const nextDue = loanSle->at(sfNextPaymentDueDate);
|
||||
std::uint32_t const prevDue = nextDue > interval ? nextDue - interval : 0;
|
||||
auto const now = view.parentCloseTime().time_since_epoch().count();
|
||||
Number const earned =
|
||||
rateBefore * Number{now > prevDue ? static_cast<std::uint32_t>(now) - prevDue : 0};
|
||||
// Late payments are capped at the schedule: the vault never recognizes
|
||||
// more than the period was ever going to earn.
|
||||
recognizedPeriod = earned >= periodScheduled ? periodScheduled : earned;
|
||||
}
|
||||
|
||||
std::expected<LoanPaymentParts, TER> const paymentParts =
|
||||
loanMakePayment(asset, view, loanSle, brokerSle, amount, paymentType, j_);
|
||||
|
||||
@@ -453,7 +480,28 @@ LoanPay::doApply()
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
|
||||
auto const [rawAssetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts);
|
||||
|
||||
// Settle at the rate that was in force over the period just ended, then take
|
||||
// the loan's new rate. The interest the clock already recognized is not
|
||||
// credited again: doing so double-counts on every on-time payment.
|
||||
Number assetsTotalDelta = rawAssetsTotalDelta;
|
||||
if (accrualVault)
|
||||
{
|
||||
accrueVault(view, vaultSle);
|
||||
assetsTotalDelta = rawAssetsTotalDelta - recognizedPeriod;
|
||||
|
||||
Number const stillUnrecognized = periodScheduled - recognizedPeriod;
|
||||
auto unearnedProxy = vaultSle->at(sfUnearnedInterest);
|
||||
unearnedProxy =
|
||||
*unearnedProxy > stillUnrecognized ? *unearnedProxy - stillUnrecognized : Number{};
|
||||
|
||||
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
|
||||
Number const rateAfter = loanAccrualRate(loanSle->at(sfPrincipalOutstanding), interestRate);
|
||||
auto rateProxy = vaultSle->at(sfAccrualRate);
|
||||
Number const nextRate = *rateProxy + rateAfter - rateBefore;
|
||||
rateProxy = nextRate > Number{} ? nextRate : Number{};
|
||||
}
|
||||
|
||||
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
|
||||
<< ", interest paid: " << paymentParts->interestPaid
|
||||
|
||||
@@ -691,6 +691,18 @@ LoanSet::doApply()
|
||||
loan->at(sfPaymentRemaining) = paymentTotal;
|
||||
view.insert(loan);
|
||||
|
||||
// An accrual vault recognizes this loan's interest over its life rather than
|
||||
// at origination. Settle first, so the period just ended is charged at the
|
||||
// rate that was in force over it, then take on the new loan's budget and
|
||||
// rate. AssetsTotal is untouched here: nothing has been earned yet.
|
||||
if (view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
getAccountingMethod(vaultSle) == kVaultAccountingAccrual)
|
||||
{
|
||||
accrueVault(view, vaultSle);
|
||||
vaultSle->at(sfUnearnedInterest) += state.interestDue;
|
||||
vaultSle->at(sfAccrualRate) += loanAccrualRate(principalRequested, interestRate);
|
||||
}
|
||||
|
||||
// Update the balances in the vault
|
||||
vaultAvailableProxy -= principalRequested;
|
||||
vaultTotalProxy += assetsTotalDelta;
|
||||
|
||||
@@ -259,7 +259,8 @@ VaultClawback::assetsToClawback(
|
||||
{
|
||||
auto const sharesDestroyed = accountHolds(
|
||||
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(view(), vault, sleShareIssuance, sharesDestroyed);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
|
||||
@@ -297,7 +298,7 @@ VaultClawback::assetsToClawback(
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
|
||||
@@ -312,13 +313,13 @@ VaultClawback::assetsToClawback(
|
||||
// below).
|
||||
auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No;
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
|
||||
view(), vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
|
||||
if (!maybeShares)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
sharesDestroyed = *maybeShares;
|
||||
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
assetsRecovered = *maybeAssets;
|
||||
@@ -330,6 +331,7 @@ VaultClawback::assetsToClawback(
|
||||
assetsRecovered = *assetsAvailable;
|
||||
{
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
view(),
|
||||
vault,
|
||||
sleShareIssuance,
|
||||
assetsRecovered,
|
||||
@@ -341,7 +343,7 @@ VaultClawback::assetsToClawback(
|
||||
}
|
||||
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
view(), vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
assetsRecovered = *maybeAssets;
|
||||
|
||||
@@ -49,6 +49,12 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx)
|
||||
ctx.tx.isFieldPresent(sfRedemptionDate)))
|
||||
return false;
|
||||
|
||||
if (!ctx.rules.enabled(featureVaultContinuousAccrual) &&
|
||||
(ctx.tx.isFieldPresent(sfDealingInterval) || ctx.tx.isFieldPresent(sfDealingWindow) ||
|
||||
ctx.tx.isFieldPresent(sfDepositFee) || ctx.tx.isFieldPresent(sfRedemptionFee) ||
|
||||
ctx.tx.isFieldPresent(sfRedemptionPeriod) || ctx.tx.isFieldPresent(sfAccountingMethod)))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -111,7 +117,8 @@ VaultCreate::preflight(PreflightContext const& ctx)
|
||||
auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate);
|
||||
auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate);
|
||||
auto const isClosedEnded = kind == VaultKind::ClosedEnded;
|
||||
if (!isClosedEnded && (hasSubscription || hasRedemption))
|
||||
auto const isRolling = kind == VaultKind::Rolling;
|
||||
if (!isClosedEnded && !isRolling && (hasSubscription || hasRedemption))
|
||||
return temMALFORMED;
|
||||
if (isClosedEnded)
|
||||
{
|
||||
@@ -121,6 +128,35 @@ VaultCreate::preflight(PreflightContext const& ctx)
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
auto const hasInterval = ctx.tx.isFieldPresent(sfDealingInterval);
|
||||
auto const hasWindow = ctx.tx.isFieldPresent(sfDealingWindow);
|
||||
if (!isRolling && (hasInterval || hasWindow))
|
||||
return temMALFORMED;
|
||||
if (isRolling)
|
||||
{
|
||||
// A rolling vault deals in a window that reopens every DealingInterval;
|
||||
// SubscriptionDate is when the first one opens. RedemptionDate belongs
|
||||
// to the closed-ended structure and has no meaning here.
|
||||
if (!hasSubscription || hasRedemption || !hasInterval || !hasWindow)
|
||||
return temMALFORMED;
|
||||
if (ctx.tx[sfDealingWindow] == 0 || ctx.tx[sfDealingWindow] >= ctx.tx[sfDealingInterval])
|
||||
return temMALFORMED;
|
||||
}
|
||||
|
||||
// Legacy is never assigned to a new vault: it recognizes a loan's whole-life
|
||||
// interest at origination, which is the defect this amendment closes.
|
||||
if (auto const method = ctx.tx[~sfAccountingMethod];
|
||||
method && (*method == kVaultAccountingLegacy || *method > kVaultAccountingAccrual))
|
||||
return temMALFORMED;
|
||||
|
||||
// A redemption period without a fee to gate would never be read.
|
||||
if (ctx.tx.isFieldPresent(sfRedemptionPeriod) && !ctx.tx.isFieldPresent(sfRedemptionFee))
|
||||
return temMALFORMED;
|
||||
|
||||
if (ctx.tx[~sfDepositFee].value_or(0) > kMaxVaultFee ||
|
||||
ctx.tx[~sfRedemptionFee].value_or(0) > kMaxVaultFee)
|
||||
return temMALFORMED;
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
@@ -284,6 +320,23 @@ VaultCreate::doApply()
|
||||
vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate];
|
||||
vault->at(sfRedemptionDate) = tx[sfRedemptionDate];
|
||||
}
|
||||
else if (kind == VaultKind::Rolling)
|
||||
{
|
||||
vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate];
|
||||
vault->at(sfDealingInterval) = tx[sfDealingInterval];
|
||||
vault->at(sfDealingWindow) = tx[sfDealingWindow];
|
||||
}
|
||||
|
||||
// Accrual is the default: it is the model the users of this protocol
|
||||
// report under. Cash basis stays available on request.
|
||||
vault->at(sfAccountingMethod) = tx[~sfAccountingMethod].value_or(kVaultAccountingAccrual);
|
||||
|
||||
if (tx.isFieldPresent(sfDepositFee))
|
||||
vault->at(sfDepositFee) = tx[sfDepositFee];
|
||||
if (tx.isFieldPresent(sfRedemptionFee))
|
||||
vault->at(sfRedemptionFee) = tx[sfRedemptionFee];
|
||||
if (tx.isFieldPresent(sfRedemptionPeriod))
|
||||
vault->at(sfRedemptionPeriod) = tx[sfRedemptionPeriod];
|
||||
}
|
||||
view().insert(vault);
|
||||
|
||||
|
||||
@@ -118,6 +118,14 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// A rolling vault deals only inside its window.
|
||||
if (ctx.view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
!inDealingWindow(ctx.view, vault))
|
||||
{
|
||||
JLOG(ctx.j.debug()) << "VaultDeposit: vault is outside its dealing window.";
|
||||
return tecTOO_SOON;
|
||||
}
|
||||
|
||||
auto const& account = ctx.tx[sfAccount];
|
||||
auto const amount = ctx.tx[sfAmount];
|
||||
auto const vaultAsset = vault->at(sfAsset);
|
||||
@@ -306,6 +314,34 @@ VaultDeposit::doApply()
|
||||
}
|
||||
}
|
||||
|
||||
// The first deal of a window fixes the price every deal in that window
|
||||
// converts at, so a participant early in a long window cannot capture a loan
|
||||
// payment that lands later in it.
|
||||
strikeWindowPrice(view(), vault, sleIssuance);
|
||||
|
||||
// The deposit fee is taken from the assets in; shares are minted for the net
|
||||
// and the fee stays in the vault, lifting every existing holder. An empty
|
||||
// vault has no holders to lift, so the first deposit pays nothing. Rounded
|
||||
// up so the rounding never favours the depositor over the holders.
|
||||
STAmount depositFee{amount.asset()};
|
||||
if (view().rules().enabled(featureVaultContinuousAccrual))
|
||||
{
|
||||
std::uint32_t const feeRate = vault->at(sfDepositFee);
|
||||
if (feeRate != 0 && *vault->at(sfAssetsTotal) != beast::kZero)
|
||||
{
|
||||
depositFee = STAmount{
|
||||
amount.asset(),
|
||||
roundToAsset(
|
||||
amount.asset(),
|
||||
tenthBipsOfValue(Number{amount}, TenthBips32{feeRate}),
|
||||
scale(amount, amount.asset()),
|
||||
Number::RoundingMode::Upward)};
|
||||
}
|
||||
}
|
||||
STAmount const netAmount = amount - depositFee;
|
||||
if (netAmount <= beast::kZero)
|
||||
return tecPRECISION_LOSS;
|
||||
|
||||
STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
|
||||
|
||||
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
|
||||
@@ -313,7 +349,7 @@ VaultDeposit::doApply()
|
||||
{
|
||||
// Compute exchange before transferring any amounts.
|
||||
{
|
||||
auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount);
|
||||
auto const maybeShares = assetsToSharesDeposit(view(), vault, sleIssuance, netAmount);
|
||||
if (!maybeShares)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
sharesCreated = *maybeShares;
|
||||
@@ -325,21 +361,21 @@ VaultDeposit::doApply()
|
||||
// Convert shares back to assets so the depositor is debited for the amount actually minted.
|
||||
// The truncated share count is worth <= amount; without this the difference would be
|
||||
// credited to the vault for free.
|
||||
auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
|
||||
auto const maybeAssets = sharesToAssetsDeposit(view(), vault, sleIssuance, sharesCreated);
|
||||
if (!maybeAssets)
|
||||
{
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
}
|
||||
// The round-trip must never return more than the original amount. If it does, a conversion
|
||||
// helper is broken. Reject rather than overcharge the depositor.
|
||||
if (*maybeAssets > amount)
|
||||
if (*maybeAssets > netAmount)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.error()) << "VaultDeposit: would take more than offered.";
|
||||
return tecINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
assetsDeposited = *maybeAssets;
|
||||
assetsDeposited = *maybeAssets + depositFee;
|
||||
|
||||
// Post-fixCleanup3_4_0: round the deposit to the sfAssetsTotal scale so all accounting
|
||||
// fields (trust line / MPT, sfAssetsAvailable, sfAssetsTotal) change by the same
|
||||
@@ -421,6 +457,22 @@ VaultDeposit::doApply()
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Start this holder's redemption period. Stamped on their own share MPToken
|
||||
// so each holder carries their own clock, and pushed out by a later deposit
|
||||
// rather than kept from the first one.
|
||||
if (view().rules().enabled(featureVaultContinuousAccrual))
|
||||
{
|
||||
if (std::uint32_t const period = vault->at(sfRedemptionPeriod); period != 0)
|
||||
{
|
||||
if (auto sleMpt = view().peek(keylet::mptoken(mptIssuanceID, accountID_)))
|
||||
{
|
||||
auto const now = view().header().parentCloseTime.time_since_epoch().count();
|
||||
sleMpt->at(sfRedemptionAfter) = static_cast<std::uint32_t>(now) + period;
|
||||
view().update(sleMpt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associateAsset(*vault, vaultAsset);
|
||||
|
||||
return tesSUCCESS;
|
||||
|
||||
@@ -97,6 +97,14 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// A rolling vault deals only inside its window.
|
||||
if (ctx.view.rules().enabled(featureVaultContinuousAccrual) &&
|
||||
!inDealingWindow(ctx.view, vault))
|
||||
{
|
||||
JLOG(ctx.j.debug()) << "VaultWithdraw: vault is outside its dealing window.";
|
||||
return tecTOO_SOON;
|
||||
}
|
||||
|
||||
auto const amount = ctx.tx[sfAmount];
|
||||
auto const vaultAsset = vault->at(sfAsset);
|
||||
auto const vaultShare = vault->at(sfShareMPTID);
|
||||
@@ -166,7 +174,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
try
|
||||
{
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss);
|
||||
sharesToAssetsWithdraw(ctx.view, vault, sleIssuance, amount, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return tefINTERNAL; // LCOV_EXCL_LINE
|
||||
|
||||
@@ -305,6 +313,11 @@ VaultWithdraw::doApply()
|
||||
|
||||
MPTIssue const share{mptIssuanceID};
|
||||
STAmount sharesRedeemed = {share};
|
||||
// The first deal of a window fixes the price every deal in that window
|
||||
// converts at, so a participant early in a long window cannot capture a loan
|
||||
// payment that lands later in it.
|
||||
strikeWindowPrice(view(), vault, sleIssuance);
|
||||
|
||||
STAmount assetsWithdrawn;
|
||||
|
||||
// When the user is the sole shareholder they own both the available and future value.
|
||||
@@ -330,7 +343,7 @@ VaultWithdraw::doApply()
|
||||
view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
|
||||
{
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
|
||||
view(), vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
|
||||
if (!maybeShares)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
sharesRedeemed = *maybeShares;
|
||||
@@ -342,8 +355,8 @@ VaultWithdraw::doApply()
|
||||
return tecPRECISION_LOSS;
|
||||
// Convert shares back to assets so the payout matches the shares actually burned, not
|
||||
// the requested amount. The extra would otherwise be paid from the vault for free.
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
view(), vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
assetsWithdrawn = *maybeAssets;
|
||||
@@ -353,8 +366,8 @@ VaultWithdraw::doApply()
|
||||
// Fixed shares, variable assets. No round-trip: the share count is exactly what the
|
||||
// caller specified; only the payout amount is derived.
|
||||
sharesRedeemed = amount;
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
view(), vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
assetsWithdrawn = *maybeAssets;
|
||||
@@ -394,7 +407,7 @@ VaultWithdraw::doApply()
|
||||
// backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch
|
||||
// above has already rejected zero via the sharesRedeemed check.
|
||||
if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
|
||||
assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
|
||||
assetsTotalForWithdrawal(view(), vault, waiveUnrealizedLoss) != beast::kZero)
|
||||
{
|
||||
JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets";
|
||||
return tecPRECISION_LOSS;
|
||||
@@ -538,6 +551,42 @@ VaultWithdraw::doApply()
|
||||
}
|
||||
else
|
||||
{
|
||||
// The redemption fee is taken from the assets out and stays in the vault,
|
||||
// lifting the holders who remain. It is waived for a sole shareholder and
|
||||
// for a final withdrawal, neither of which leaves anyone to lift, and with
|
||||
// a redemption period set it applies only while the holder is inside it.
|
||||
// Rounded up so the rounding never favours the leaver over the stayers.
|
||||
if (view().rules().enabled(featureVaultContinuousAccrual) &&
|
||||
waiveUnrealizedLoss == WaiveUnrealizedLoss::No)
|
||||
{
|
||||
std::uint32_t const feeRate = vault->at(sfRedemptionFee);
|
||||
bool charge = feeRate != 0;
|
||||
if (charge)
|
||||
{
|
||||
if (std::uint32_t const period = vault->at(sfRedemptionPeriod); period != 0)
|
||||
{
|
||||
auto const sleMpt = view().read(keylet::mptoken(mptIssuanceID, accountID_));
|
||||
std::uint32_t const redeemAfter =
|
||||
sleMpt ? sleMpt->at(sfRedemptionAfter) : std::uint32_t{0};
|
||||
auto const now = view().header().parentCloseTime.time_since_epoch().count();
|
||||
charge = now < redeemAfter;
|
||||
}
|
||||
}
|
||||
|
||||
if (charge)
|
||||
{
|
||||
auto const fee = STAmount{
|
||||
assetsWithdrawn.asset(),
|
||||
roundToAsset(
|
||||
assetsWithdrawn.asset(),
|
||||
tenthBipsOfValue(Number{assetsWithdrawn}, TenthBips32{feeRate}),
|
||||
scale(assetsWithdrawn, assetsWithdrawn.asset()),
|
||||
Number::RoundingMode::Upward)};
|
||||
if (fee < assetsWithdrawn)
|
||||
assetsWithdrawn -= fee;
|
||||
}
|
||||
}
|
||||
|
||||
// Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step,
|
||||
// as required by the ValidVault invariant.
|
||||
assetsTotal -= assetsWithdrawn;
|
||||
|
||||
322
src/test/app/lending/LoanAccrual_test.cpp
Normal file
322
src/test/app/lending/LoanAccrual_test.cpp
Normal file
@@ -0,0 +1,322 @@
|
||||
#include <test/app/lending/LoanTestBase.h>
|
||||
#include <test/jtx/Account.h>
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/TestHelpers.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/fee.h>
|
||||
#include <test/jtx/ter.h>
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/Units.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
/**
|
||||
* Continuous accrual, XLS Vault Continuous Accrual sections 4.1 and 4.2.
|
||||
*/
|
||||
class LoanAccrual_test : public LoanTestBase
|
||||
{
|
||||
private:
|
||||
// Origination takes on the loan's interest as a budget and a rate, and
|
||||
// leaves AssetsTotal alone: nothing has been earned yet.
|
||||
void
|
||||
testAccrualLoanSetOrigination()
|
||||
{
|
||||
testcase("accrual: LoanSet origination");
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingAccrual};
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const vaultBefore = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultBefore);
|
||||
if (!vaultBefore)
|
||||
return;
|
||||
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
|
||||
BEAST_EXPECT(vaultBefore->at(sfUnearnedInterest) == Number{});
|
||||
BEAST_EXPECT(vaultBefore->at(sfAccrualRate) == Number{});
|
||||
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(brokerBefore);
|
||||
if (!brokerBefore)
|
||||
return;
|
||||
auto const loanKeylet =
|
||||
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence)));
|
||||
|
||||
env(set(borrower, broker.brokerID, xrpAsset(10'000).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(TenthBips32{percentageToTenthBips(12)}),
|
||||
kPaymentTotal(4),
|
||||
kPaymentInterval(600),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const loanSle = env.le(loanKeylet);
|
||||
auto const vaultAfter = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(loanSle && vaultAfter);
|
||||
if (!loanSle || !vaultAfter)
|
||||
return;
|
||||
|
||||
Number const interestDue =
|
||||
Number{loanSle->at(sfTotalValueOutstanding)} - loanSle->at(sfPrincipalOutstanding);
|
||||
BEAST_EXPECT(interestDue > Number{});
|
||||
|
||||
// Nothing earned yet, so AssetsTotal has not moved.
|
||||
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore);
|
||||
// The whole of the loan's interest is now the budget the clock may draw
|
||||
// down, and the vault carries the loan's rate.
|
||||
BEAST_EXPECT(vaultAfter->at(sfUnearnedInterest) == interestDue);
|
||||
BEAST_EXPECT(vaultAfter->at(sfAccrualRate) > Number{});
|
||||
BEAST_EXPECT(vaultAfter->at(sfLastAccrualTime) > 0u);
|
||||
}
|
||||
|
||||
// The single most important case in the specification: an on-time payment
|
||||
// must not credit interest the clock has already recognized.
|
||||
void
|
||||
testAccrualLoanPayDoesNotDoubleCount()
|
||||
{
|
||||
testcase("accrual: on-time LoanPay does not double count");
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingAccrual};
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(brokerBefore);
|
||||
if (!brokerBefore)
|
||||
return;
|
||||
auto const loanKeylet =
|
||||
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence)));
|
||||
|
||||
std::uint32_t const paymentInterval = 600;
|
||||
env(set(borrower, broker.brokerID, xrpAsset(10'000).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(TenthBips32{percentageToTenthBips(12)}),
|
||||
kPaymentTotal(4),
|
||||
kPaymentInterval(paymentInterval),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const loanSle = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanSle);
|
||||
if (!loanSle)
|
||||
return;
|
||||
Number const interestDue =
|
||||
Number{loanSle->at(sfTotalValueOutstanding)} - loanSle->at(sfPrincipalOutstanding);
|
||||
|
||||
// Stand just inside the first period, so nearly all of it has been
|
||||
// recognized by the clock and the payment is still on time.
|
||||
env.close(NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) - 30}});
|
||||
|
||||
auto const vaultAtDue = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultAtDue);
|
||||
if (!vaultAtDue)
|
||||
return;
|
||||
Number const assetsTotalAtDue = vaultAtDue->at(sfAssetsTotal);
|
||||
Number const unearnedAtDue = vaultAtDue->at(sfUnearnedInterest);
|
||||
|
||||
auto const loanAtDue = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanAtDue);
|
||||
if (!loanAtDue)
|
||||
return;
|
||||
Number const principalBefore = loanAtDue->at(sfPrincipalOutstanding);
|
||||
|
||||
// The stored periodic payment is unrounded; a payment must cover it, so
|
||||
// round up to the loan's scale.
|
||||
STAmount const paymentAmount{
|
||||
broker.asset.raw(),
|
||||
roundToAsset(
|
||||
broker.asset.raw(),
|
||||
Number{loanSle->at(sfPeriodicPayment)},
|
||||
loanSle->at(sfLoanScale),
|
||||
Number::RoundingMode::Upward)};
|
||||
env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfterPay = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultAfterPay);
|
||||
if (!vaultAfterPay)
|
||||
return;
|
||||
|
||||
Number const assetsGained = Number{vaultAfterPay->at(sfAssetsTotal)} - assetsTotalAtDue;
|
||||
|
||||
// The interest this payment actually carried: with no management fee,
|
||||
// whatever did not reduce the principal.
|
||||
auto const loanAfterPay = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanAfterPay);
|
||||
if (!loanAfterPay)
|
||||
return;
|
||||
Number const principalPaid =
|
||||
principalBefore - Number{loanAfterPay->at(sfPrincipalOutstanding)};
|
||||
Number const interestPaid = Number{paymentAmount} - principalPaid;
|
||||
BEAST_EXPECT(interestPaid > Number{});
|
||||
|
||||
// Settling at the payment credits what the clock earned over the period,
|
||||
// and the payment itself adds only the remainder the clock had not yet
|
||||
// reached. Were the received interest credited again on top, the vault
|
||||
// would gain about twice the interest paid: that is the double count
|
||||
// this accounting exists to avoid.
|
||||
BEAST_EXPECT(assetsGained > Number{});
|
||||
BEAST_EXPECT(assetsGained < interestPaid + (interestPaid / Number{2}));
|
||||
|
||||
// The budget shrinks: the period just paid is no longer owed.
|
||||
BEAST_EXPECT(vaultAfterPay->at(sfUnearnedInterest) < unearnedAtDue);
|
||||
// Never negative, whatever the rounding.
|
||||
BEAST_EXPECT(vaultAfterPay->at(sfUnearnedInterest) >= Number{});
|
||||
BEAST_EXPECT(vaultAfterPay->at(sfAccrualRate) >= Number{});
|
||||
}
|
||||
|
||||
// Impairing a loan stops it accruing; clearing the impairment takes its rate back on.
|
||||
void
|
||||
testAccrualImpairHaltsAccrual()
|
||||
{
|
||||
testcase("accrual: impair halts accrual");
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingAccrual};
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(brokerBefore);
|
||||
if (!brokerBefore)
|
||||
return;
|
||||
auto const loanKeylet =
|
||||
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerBefore->at(sfLoanSequence)));
|
||||
|
||||
env(set(borrower, broker.brokerID, xrpAsset(10'000).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(TenthBips32{percentageToTenthBips(12)}),
|
||||
kPaymentTotal(4),
|
||||
kPaymentInterval(600),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const loanSle = env.le(loanKeylet);
|
||||
auto const vaultLent = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(loanSle && vaultLent);
|
||||
if (!loanSle || !vaultLent)
|
||||
return;
|
||||
Number const rateWhileLending = vaultLent->at(sfAccrualRate);
|
||||
BEAST_EXPECT(rateWhileLending > Number{});
|
||||
|
||||
// A loan can only be impaired once it is late.
|
||||
env.close(NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) + 60}});
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultImpaired = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultImpaired);
|
||||
if (!vaultImpaired)
|
||||
return;
|
||||
// The vault carries only this loan, so its rate falls to nothing.
|
||||
BEAST_EXPECT(vaultImpaired->at(sfAccrualRate) < rateWhileLending);
|
||||
BEAST_EXPECT(vaultImpaired->at(sfAccrualRate) == Number{});
|
||||
|
||||
// An impaired loan earns nothing, so the budget does not move while it
|
||||
// stays impaired.
|
||||
Number const unearnedImpaired = vaultImpaired->at(sfUnearnedInterest);
|
||||
Number const assetsImpaired = vaultImpaired->at(sfAssetsTotal);
|
||||
env.close(
|
||||
NetClock::time_point{NetClock::duration{loanSle->at(sfNextPaymentDueDate) + 100'000}});
|
||||
{
|
||||
auto const vaultIdle = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultIdle);
|
||||
if (!vaultIdle)
|
||||
return;
|
||||
BEAST_EXPECT(vaultIdle->at(sfUnearnedInterest) == unearnedImpaired);
|
||||
BEAST_EXPECT(vaultIdle->at(sfAssetsTotal) == assetsImpaired);
|
||||
}
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultRestored = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultRestored);
|
||||
if (!vaultRestored)
|
||||
return;
|
||||
BEAST_EXPECT(vaultRestored->at(sfAccrualRate) == rateWhileLending);
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testAccrualLoanSetOrigination();
|
||||
testAccrualLoanPayDoesNotDoubleCount();
|
||||
testAccrualImpairHaltsAccrual();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(LoanAccrual, tx, xrpl);
|
||||
|
||||
} // namespace xrpl::test
|
||||
@@ -63,7 +63,8 @@ private:
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Number const principalRequest{10'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(10)};
|
||||
@@ -273,7 +274,8 @@ private:
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Number const principalRequest{12'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(12)};
|
||||
@@ -515,7 +517,8 @@ private:
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
auto run =
|
||||
[&](FeatureBitset features, TER expectedOverCapSet, bool native, bool vaultPrivate) {
|
||||
@@ -665,7 +668,8 @@ private:
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
.coverRateLiquidation = TenthBips32{0},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
@@ -758,7 +762,8 @@ private:
|
||||
.coverRateMin = TenthBips32{percentageToTenthBips(10)},
|
||||
.coverDeposit = 5'000,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}};
|
||||
.coverRateLiquidation = TenthBips32{percentageToTenthBips(25)},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Number const principalRequest{10'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(12)};
|
||||
@@ -952,7 +957,8 @@ private:
|
||||
.coverRateMin = TenthBips32{percentageToTenthBips(10)},
|
||||
.coverDeposit = 5'000,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}};
|
||||
.coverRateLiquidation = TenthBips32{percentageToTenthBips(25)},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Number const principalRequest{10'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(12)};
|
||||
@@ -1145,7 +1151,9 @@ private:
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}};
|
||||
.vaultDeposit = 100'000,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.accountingMethod = kVaultAccountingCash};
|
||||
|
||||
Env env(*this, all_ | featureLendingProtocolV1_1);
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ protected:
|
||||
// Useful for tests that need to observe the vault while it is still in the Subscription
|
||||
// phase. Ignored for open-ended vaults.
|
||||
bool skipPhaseAdvance = false;
|
||||
// Interest recognition method for the vault. Absent takes the default
|
||||
// for the amendments in force, which is accrual from
|
||||
// featureVaultContinuousAccrual onwards.
|
||||
std::optional<std::uint8_t> accountingMethod =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
|
||||
[[nodiscard]] Number
|
||||
maxCoveredLoanValue(Number const& currentDebt) const
|
||||
@@ -535,7 +540,8 @@ protected:
|
||||
? std::optional<std::uint8_t>{}
|
||||
: std::optional<std::uint8_t>{std::to_underlying(effectiveVaultKind)},
|
||||
.subscriptionDate = subscriptionDate,
|
||||
.redemptionDate = redemptionDate});
|
||||
.redemptionDate = redemptionDate,
|
||||
.accountingMethod = params.accountingMethod});
|
||||
if (params.vaultScale)
|
||||
tx[sfScale] = *params.vaultScale;
|
||||
env(tx);
|
||||
|
||||
@@ -584,8 +584,17 @@ private:
|
||||
// Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds.
|
||||
build(all_, tesSUCCESS, tesSUCCESS);
|
||||
|
||||
// LP V1.1 enabled -> open-ended vault + broker rejected on create.
|
||||
build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION);
|
||||
// LP V1.1 enabled, V1.2 disabled -> open-ended vault + broker rejected
|
||||
// on create.
|
||||
build(
|
||||
(all_ - featureVaultContinuousAccrual) | featureLendingProtocolV1_1, tecNO_PERMISSION);
|
||||
|
||||
// LP V1.2 -> a vault created under it prices by accrual, which has no
|
||||
// step to front-run, so an open-ended vault may host a broker again.
|
||||
build(
|
||||
all_ | featureLendingProtocolV1_1 | featureVaultContinuousAccrual,
|
||||
tesSUCCESS,
|
||||
tesSUCCESS);
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
@@ -1521,6 +1521,7 @@ private:
|
||||
// same conversion helper VaultClawback itself uses, rather than
|
||||
// assuming an exact 90/10 split holds under truncation.
|
||||
auto const maybeSharesDestroyed = assetsToSharesWithdraw(
|
||||
*env.current(),
|
||||
vaultBefore,
|
||||
issuanceBefore,
|
||||
setup.usd(9'000).value(),
|
||||
|
||||
426
src/test/app/vault/VaultRolling_test.cpp
Normal file
426
src/test/app/vault/VaultRolling_test.cpp
Normal file
@@ -0,0 +1,426 @@
|
||||
#include <test/app/vault/VaultTestBase.h>
|
||||
#include <test/jtx/Account.h>
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/TestHelpers.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/ter.h>
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <utility>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
class VaultRolling_test : public VaultTestBase
|
||||
{
|
||||
private:
|
||||
static constexpr std::uint32_t kInterval = 86'400; // a day between windows
|
||||
static constexpr std::uint32_t kWindow = 3'600; // open for an hour
|
||||
|
||||
// VaultCreate validation for VaultKind::Rolling and the fee fields, plus
|
||||
// the featureVaultContinuousAccrual gate.
|
||||
void
|
||||
testVaultCreateRolling()
|
||||
{
|
||||
testcase("rolling VaultCreate");
|
||||
using namespace test::jtx;
|
||||
|
||||
auto const withEnv = [this](FeatureBitset features, auto&& body) {
|
||||
Env env{*this, features};
|
||||
Account const owner{"owner"};
|
||||
env.fund(XRP(1000), owner);
|
||||
env.close();
|
||||
Vault vault{env};
|
||||
body(env, owner, vault);
|
||||
};
|
||||
|
||||
Asset const asset = xrpIssue();
|
||||
auto const rolling = std::to_underlying(VaultKind::Rolling);
|
||||
auto const openEnded = std::to_underlying(VaultKind::OpenEnded);
|
||||
|
||||
// Gate: the dealing and fee fields require featureVaultContinuousAccrual.
|
||||
withEnv(
|
||||
testableAmendments() - featureVaultContinuousAccrual,
|
||||
[&](Env& env, Account const& owner, Vault& vault) {
|
||||
auto const sub = env.now().time_since_epoch().count() + 60;
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx, Ter{temDISABLED});
|
||||
env.close();
|
||||
});
|
||||
|
||||
withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
|
||||
auto const sub = static_cast<std::uint32_t>(env.now().time_since_epoch().count()) + 60;
|
||||
|
||||
// A rolling vault needs a first window and both durations.
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.dealingInterval = kInterval});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// 0 < DealingWindow < DealingInterval.
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = 0});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kInterval});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// RedemptionDate belongs to the closed-ended structure.
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = sub,
|
||||
.redemptionDate = sub + kInterval,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// The dealing fields mean nothing on a vault that is not rolling.
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = openEnded,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// A redemption period with no fee to gate would never be read.
|
||||
{
|
||||
auto [tx, keylet] =
|
||||
vault.create({.owner = owner, .asset = asset, .redemptionPeriod = kInterval});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// Neither fee may retain more than half of what is moved.
|
||||
{
|
||||
auto [tx, keylet] =
|
||||
vault.create({.owner = owner, .asset = asset, .depositFee = kMaxVaultFee + 1});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
{
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner, .asset = asset, .redemptionFee = kMaxVaultFee + 1});
|
||||
env(tx, Ter{temMALFORMED});
|
||||
env.close();
|
||||
}
|
||||
|
||||
// The happy path stores every field. The rejected cases above each
|
||||
// closed a ledger, so take the first window from the clock as it is
|
||||
// now rather than the value read before them.
|
||||
{
|
||||
auto const subNow =
|
||||
static_cast<std::uint32_t>(env.now().time_since_epoch().count()) + 60;
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = rolling,
|
||||
.subscriptionDate = subNow,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow,
|
||||
.depositFee = 100,
|
||||
.redemptionFee = 250,
|
||||
.redemptionPeriod = kInterval});
|
||||
env(tx);
|
||||
env.close();
|
||||
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfVaultKind) == rolling);
|
||||
BEAST_EXPECT(sleVault->at(sfSubscriptionDate) == subNow);
|
||||
BEAST_EXPECT(sleVault->at(sfDealingInterval) == kInterval);
|
||||
BEAST_EXPECT(sleVault->at(sfDealingWindow) == kWindow);
|
||||
BEAST_EXPECT(sleVault->at(sfDepositFee) == 100);
|
||||
BEAST_EXPECT(sleVault->at(sfRedemptionFee) == 250);
|
||||
BEAST_EXPECT(sleVault->at(sfRedemptionPeriod) == kInterval);
|
||||
BEAST_EXPECT(getVaultKind(sleVault) == VaultKind::Rolling);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// VaultDeposit and VaultWithdraw are accepted only inside a dealing window.
|
||||
void
|
||||
testDealingWindow()
|
||||
{
|
||||
testcase("rolling dealing window");
|
||||
using namespace test::jtx;
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const owner{"owner"};
|
||||
Account const depositor{"depositor"};
|
||||
env.fund(XRP(10'000), owner, depositor);
|
||||
env.close();
|
||||
|
||||
Vault vault{env};
|
||||
Asset const asset = xrpIssue();
|
||||
auto const start = static_cast<std::uint32_t>(env.now().time_since_epoch().count()) + 60;
|
||||
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = asset,
|
||||
.vaultKind = std::to_underlying(VaultKind::Rolling),
|
||||
.subscriptionDate = start,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx);
|
||||
env.close();
|
||||
|
||||
auto const vaultId = keylet.key;
|
||||
auto const atTime = [&](std::uint32_t when) {
|
||||
env.close(NetClock::time_point{NetClock::duration{when}});
|
||||
};
|
||||
|
||||
// Before the first window opens.
|
||||
atTime(start - 30);
|
||||
env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}),
|
||||
Ter{tecTOO_SOON});
|
||||
env.close();
|
||||
|
||||
// Inside the first window.
|
||||
atTime(start + 10);
|
||||
env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}));
|
||||
env.close();
|
||||
|
||||
// After the window has closed, before the next one opens.
|
||||
atTime(start + kWindow + 10);
|
||||
env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}),
|
||||
Ter{tecTOO_SOON});
|
||||
env.close();
|
||||
|
||||
// The window reopens one interval later.
|
||||
atTime(start + kInterval + 10);
|
||||
env(vault.deposit({.depositor = depositor, .id = vaultId, .amount = XRP(10)}));
|
||||
env.close();
|
||||
|
||||
// Withdrawal obeys the same window.
|
||||
atTime(start + kInterval + kWindow + 10);
|
||||
env(vault.withdraw({.depositor = depositor, .id = vaultId, .amount = XRP(5)}),
|
||||
Ter{tecTOO_SOON});
|
||||
env.close();
|
||||
|
||||
atTime(start + 2 * kInterval + 10);
|
||||
env(vault.withdraw({.depositor = depositor, .id = vaultId, .amount = XRP(5)}));
|
||||
env.close();
|
||||
}
|
||||
|
||||
// The deposit fee is retained by the vault, so the vault gains the gross
|
||||
// while the depositor is issued shares only for the net.
|
||||
void
|
||||
testDepositFee()
|
||||
{
|
||||
testcase("deposit fee");
|
||||
using namespace test::jtx;
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const owner{"owner"};
|
||||
Account const first{"first"};
|
||||
Account const second{"second"};
|
||||
env.fund(XRP(10'000), owner, first, second);
|
||||
env.close();
|
||||
|
||||
Vault vault{env};
|
||||
// 10% of what comes in.
|
||||
auto [tx, keylet] =
|
||||
vault.create({.owner = owner, .asset = xrpIssue(), .depositFee = 10'000});
|
||||
env(tx);
|
||||
env.close();
|
||||
auto const vaultId = keylet.key;
|
||||
|
||||
// The first deposit into an empty vault has no holders to lift, so it
|
||||
// pays no fee: the vault gains exactly what was sent.
|
||||
env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(1'000)}));
|
||||
env.close();
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfAssetsTotal) == Number{1'000'000'000});
|
||||
}
|
||||
|
||||
// The second deposit pays the fee. The vault still gains the gross,
|
||||
// which is what lifts the first depositor.
|
||||
env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(1'000)}));
|
||||
env.close();
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfAssetsTotal) == Number{2'000'000'000});
|
||||
}
|
||||
}
|
||||
|
||||
// The first deal of a window fixes the price for that window, and the next
|
||||
// window strikes a fresh one.
|
||||
void
|
||||
testStruckPrice()
|
||||
{
|
||||
testcase("struck price");
|
||||
using namespace test::jtx;
|
||||
|
||||
Env env{*this, testableAmendments()};
|
||||
Account const owner{"owner"};
|
||||
Account const first{"first"};
|
||||
Account const second{"second"};
|
||||
env.fund(XRP(10'000), owner, first, second);
|
||||
env.close();
|
||||
|
||||
Vault vault{env};
|
||||
auto const start = static_cast<std::uint32_t>(env.now().time_since_epoch().count()) + 60;
|
||||
auto [tx, keylet] = vault.create(
|
||||
{.owner = owner,
|
||||
.asset = xrpIssue(),
|
||||
.vaultKind = std::to_underlying(VaultKind::Rolling),
|
||||
.subscriptionDate = start,
|
||||
.dealingInterval = kInterval,
|
||||
.dealingWindow = kWindow});
|
||||
env(tx);
|
||||
env.close();
|
||||
auto const vaultId = keylet.key;
|
||||
|
||||
auto const atTime = [&](std::uint32_t when) {
|
||||
env.close(NetClock::time_point{NetClock::duration{when}});
|
||||
};
|
||||
|
||||
// Nothing is struck before the first deal.
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault && sleVault->at(sfStruckUntil) == 0);
|
||||
}
|
||||
|
||||
// The first window seeds the vault. An empty vault has no outstanding
|
||||
// shares, so there is no ratio to strike and the window passes without a strike.
|
||||
atTime(start + 10);
|
||||
env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(1'000)}));
|
||||
env.close();
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault && sleVault->at(sfStruckUntil) == 0);
|
||||
}
|
||||
|
||||
// The first deal of the next window strikes, against that window's end.
|
||||
auto const secondWindow = start + kInterval;
|
||||
atTime(secondWindow + 10);
|
||||
env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(500)}));
|
||||
env.close();
|
||||
|
||||
Number struckPrice;
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfStruckUntil) == secondWindow + kWindow);
|
||||
struckPrice = sleVault->at(sfStruckPrice);
|
||||
BEAST_EXPECT(struckPrice > Number{});
|
||||
}
|
||||
|
||||
// A later deal in the same window converts at the same price and does
|
||||
// not restrike it.
|
||||
env(vault.deposit({.depositor = first, .id = vaultId, .amount = XRP(100)}));
|
||||
env.close();
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfStruckUntil) == secondWindow + kWindow);
|
||||
BEAST_EXPECT(sleVault->at(sfStruckPrice) == struckPrice);
|
||||
}
|
||||
|
||||
// The window after that strikes afresh.
|
||||
auto const thirdWindow = start + 2 * kInterval;
|
||||
atTime(thirdWindow + 10);
|
||||
env(vault.deposit({.depositor = second, .id = vaultId, .amount = XRP(100)}));
|
||||
env.close();
|
||||
{
|
||||
auto const sleVault = env.le(keylet);
|
||||
BEAST_EXPECT(sleVault != nullptr);
|
||||
if (!sleVault)
|
||||
return;
|
||||
BEAST_EXPECT(sleVault->at(sfStruckUntil) == thirdWindow + kWindow);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testVaultCreateRolling();
|
||||
testDealingWindow();
|
||||
testDepositFee();
|
||||
testStruckPrice();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE_PRIO(VaultRolling, app, xrpl, 1);
|
||||
|
||||
} // namespace xrpl
|
||||
@@ -41,6 +41,18 @@ Vault::create(CreateArgs const& args) const
|
||||
jv[sfRedemptionDate] = *args.redemptionDate;
|
||||
if (args.leVersion)
|
||||
jv[sfLEVersion] = std::to_underlying(*args.leVersion);
|
||||
if (args.dealingInterval)
|
||||
jv[sfDealingInterval] = *args.dealingInterval;
|
||||
if (args.dealingWindow)
|
||||
jv[sfDealingWindow] = *args.dealingWindow;
|
||||
if (args.depositFee)
|
||||
jv[sfDepositFee] = *args.depositFee;
|
||||
if (args.redemptionFee)
|
||||
jv[sfRedemptionFee] = *args.redemptionFee;
|
||||
if (args.redemptionPeriod)
|
||||
jv[sfRedemptionPeriod] = *args.redemptionPeriod;
|
||||
if (args.accountingMethod)
|
||||
jv[sfAccountingMethod] = *args.accountingMethod;
|
||||
return {jv, keylet};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,18 @@ struct Vault
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<VaultVersion> leVersion =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> dealingInterval =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> dealingWindow =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> depositFee =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> redemptionFee =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> redemptionPeriod =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint8_t> accountingMethod =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,8 +32,12 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
auto const assetsAvailableValue = canonical_NUMBER();
|
||||
auto const assetsMaximumValue = canonical_NUMBER();
|
||||
auto const lossUnrealizedValue = canonical_NUMBER();
|
||||
auto const unearnedInterestValue = canonical_NUMBER();
|
||||
auto const accrualRateValue = canonical_NUMBER();
|
||||
auto const lastAccrualTimeValue = canonical_UINT32();
|
||||
auto const shareMPTIDValue = canonical_UINT192();
|
||||
auto const withdrawalPolicyValue = canonical_UINT8();
|
||||
auto const accountingMethodValue = canonical_UINT8();
|
||||
auto const scaleValue = canonical_UINT8();
|
||||
auto const lEVersionValue = canonical_UINT8();
|
||||
auto const vaultKindValue = canonical_UINT8();
|
||||
@@ -57,6 +61,10 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
builder.setAssetsAvailable(assetsAvailableValue);
|
||||
builder.setAssetsMaximum(assetsMaximumValue);
|
||||
builder.setLossUnrealized(lossUnrealizedValue);
|
||||
builder.setUnearnedInterest(unearnedInterestValue);
|
||||
builder.setAccrualRate(accrualRateValue);
|
||||
builder.setLastAccrualTime(lastAccrualTimeValue);
|
||||
builder.setAccountingMethod(accountingMethodValue);
|
||||
builder.setScale(scaleValue);
|
||||
builder.setLEVersion(lEVersionValue);
|
||||
builder.setVaultKind(vaultKindValue);
|
||||
@@ -166,6 +174,38 @@ TEST(VaultTests, BuilderSettersRoundTrip)
|
||||
EXPECT_TRUE(entry.hasLossUnrealized());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = unearnedInterestValue;
|
||||
auto const actualOpt = entry.getUnearnedInterest();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfUnearnedInterest");
|
||||
EXPECT_TRUE(entry.hasUnearnedInterest());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = accrualRateValue;
|
||||
auto const actualOpt = entry.getAccrualRate();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfAccrualRate");
|
||||
EXPECT_TRUE(entry.hasAccrualRate());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = lastAccrualTimeValue;
|
||||
auto const actualOpt = entry.getLastAccrualTime();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfLastAccrualTime");
|
||||
EXPECT_TRUE(entry.hasLastAccrualTime());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = accountingMethodValue;
|
||||
auto const actualOpt = entry.getAccountingMethod();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfAccountingMethod");
|
||||
EXPECT_TRUE(entry.hasAccountingMethod());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = scaleValue;
|
||||
auto const actualOpt = entry.getScale();
|
||||
@@ -231,8 +271,12 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
auto const assetsAvailableValue = canonical_NUMBER();
|
||||
auto const assetsMaximumValue = canonical_NUMBER();
|
||||
auto const lossUnrealizedValue = canonical_NUMBER();
|
||||
auto const unearnedInterestValue = canonical_NUMBER();
|
||||
auto const accrualRateValue = canonical_NUMBER();
|
||||
auto const lastAccrualTimeValue = canonical_UINT32();
|
||||
auto const shareMPTIDValue = canonical_UINT192();
|
||||
auto const withdrawalPolicyValue = canonical_UINT8();
|
||||
auto const accountingMethodValue = canonical_UINT8();
|
||||
auto const scaleValue = canonical_UINT8();
|
||||
auto const lEVersionValue = canonical_UINT8();
|
||||
auto const vaultKindValue = canonical_UINT8();
|
||||
@@ -253,8 +297,12 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
sle->at(sfAssetsAvailable) = assetsAvailableValue;
|
||||
sle->at(sfAssetsMaximum) = assetsMaximumValue;
|
||||
sle->at(sfLossUnrealized) = lossUnrealizedValue;
|
||||
sle->at(sfUnearnedInterest) = unearnedInterestValue;
|
||||
sle->at(sfAccrualRate) = accrualRateValue;
|
||||
sle->at(sfLastAccrualTime) = lastAccrualTimeValue;
|
||||
sle->at(sfShareMPTID) = shareMPTIDValue;
|
||||
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
|
||||
sle->at(sfAccountingMethod) = accountingMethodValue;
|
||||
sle->at(sfScale) = scaleValue;
|
||||
sle->at(sfLEVersion) = lEVersionValue;
|
||||
sle->at(sfVaultKind) = vaultKindValue;
|
||||
@@ -425,6 +473,58 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfLossUnrealized");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = unearnedInterestValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getUnearnedInterest();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getUnearnedInterest();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfUnearnedInterest");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfUnearnedInterest");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = accrualRateValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getAccrualRate();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getAccrualRate();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfAccrualRate");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfAccrualRate");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = lastAccrualTimeValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getLastAccrualTime();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getLastAccrualTime();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfLastAccrualTime");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfLastAccrualTime");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = accountingMethodValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getAccountingMethod();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getAccountingMethod();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfAccountingMethod");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfAccountingMethod");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = scaleValue;
|
||||
|
||||
@@ -570,6 +670,14 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
|
||||
EXPECT_FALSE(entry.getAssetsMaximum().has_value());
|
||||
EXPECT_FALSE(entry.hasLossUnrealized());
|
||||
EXPECT_FALSE(entry.getLossUnrealized().has_value());
|
||||
EXPECT_FALSE(entry.hasUnearnedInterest());
|
||||
EXPECT_FALSE(entry.getUnearnedInterest().has_value());
|
||||
EXPECT_FALSE(entry.hasAccrualRate());
|
||||
EXPECT_FALSE(entry.getAccrualRate().has_value());
|
||||
EXPECT_FALSE(entry.hasLastAccrualTime());
|
||||
EXPECT_FALSE(entry.getLastAccrualTime().has_value());
|
||||
EXPECT_FALSE(entry.hasAccountingMethod());
|
||||
EXPECT_FALSE(entry.getAccountingMethod().has_value());
|
||||
EXPECT_FALSE(entry.hasScale());
|
||||
EXPECT_FALSE(entry.getScale().has_value());
|
||||
EXPECT_FALSE(entry.hasLEVersion());
|
||||
|
||||
Reference in New Issue
Block a user