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