featureVaultContinuousAccrual

This commit is contained in:
Denis Angell
2026-09-12 06:41:06 -04:00
parent 9403736199
commit bb60431ebf
30 changed files with 1985 additions and 53 deletions

View File

@@ -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
*/

View File

@@ -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,

View File

@@ -307,6 +307,19 @@ constexpr std::size_t kMaxDataPayloadLength = 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
*/
@@ -316,7 +329,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
@@ -330,12 +349,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,
};
/**

View File

@@ -15,6 +15,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FEATURE(VaultContinuousAccrual, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo)

View File

@@ -437,6 +437,7 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({
{sfIssuerEncryptedBalance, SoeOptional},
{sfAuditorEncryptedBalance, SoeOptional},
{sfHolderEncryptionKey, SoeOptional},
{sfRedemptionAfter, SoeDefault},
}))
/** A ledger object which tracks Oracle
@@ -511,13 +512,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)
}))

View File

@@ -28,6 +28,7 @@ TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19)
TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20)
TYPED_SFIELD(sfContractResult, UINT8, 21)
TYPED_SFIELD(sfVaultKind, UINT8, 22)
TYPED_SFIELD(sfAccountingMethod, UINT8, 23)
// 16-bit integers (common)
TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever)
@@ -128,6 +129,14 @@ TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 82)
TYPED_SFIELD(sfGasPrice, UINT32, 83)
TYPED_SFIELD(sfGas, UINT32, 84)
TYPED_SFIELD(sfGasUsed, UINT32, 85)
TYPED_SFIELD(sfLastAccrualTime, UINT32, 86)
TYPED_SFIELD(sfDealingInterval, UINT32, 87)
TYPED_SFIELD(sfDealingWindow, UINT32, 88)
TYPED_SFIELD(sfStruckUntil, UINT32, 89)
TYPED_SFIELD(sfDepositFee, UINT32, 90)
TYPED_SFIELD(sfRedemptionFee, UINT32, 91)
TYPED_SFIELD(sfRedemptionPeriod, UINT32, 92)
TYPED_SFIELD(sfRedemptionAfter, UINT32, 93)
// 64-bit integers (common)
TYPED_SFIELD(sfIndexNext, UINT64, 1)
@@ -239,6 +248,11 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfUnearnedInterest, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
// Assets earned per second, summed over a vault's performing loans. A rate,
// not an amount, so it carries no asset association.
TYPED_SFIELD(sfAccrualRate, NUMBER, 19, SField::kSmdDefault)
TYPED_SFIELD(sfStruckPrice, NUMBER, 20, SField::kSmdDefault)
// 32-bit signed (common)
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -788,6 +788,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. */
@@ -804,6 +810,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. */

View File

@@ -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.

View File

@@ -16,6 +16,7 @@
#include <xrpl/tx/invariants/PermissionedDEXInvariant.h>
#include <xrpl/tx/invariants/PermissionedDomainInvariant.h>
#include <xrpl/tx/invariants/SponsorshipInvariant.h>
#include <xrpl/tx/invariants/VaultAccrualInvariant.h>
#include <xrpl/tx/invariants/VaultInvariant.h>
#include <cstdint>
@@ -457,6 +458,7 @@ using InvariantChecks = std::tuple<
ValidLoanBroker,
ValidLoan,
ValidVault,
ValidVaultAccrual,
ValidConfidentialMPToken,
ValidMPTBalanceChanges,
ValidAmounts,

View 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

View File

@@ -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);
}

View File

@@ -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

View 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

View File

@@ -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.";

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View 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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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(),

View 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

View File

@@ -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};
}

View File

@@ -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)
};
/**

View File

@@ -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());