Compare commits

...

2 Commits

Author SHA1 Message Date
Vito Tumas
5ce85b2799 feat: Add FixedPrecision Vault grid and balance helpers (#8257)
Co-authored-by: Jingchen <a1q123456@users.noreply.github.com>
2026-10-13 16:19:08 +00:00
Ayaz Salikhov
4538d198e1 build: Update codegen requirements (#8570) 2026-10-13 14:29:59 +00:00
15 changed files with 2186 additions and 95 deletions

View File

@@ -36,3 +36,18 @@ updates:
rust-dependencies:
patterns:
- "*"
- package-ecosystem: uv
directory: /cmake/scripts/codegen
schedule:
interval: weekly
day: monday
time: "04:00"
timezone: Etc/GMT
commit-message:
prefix: "chore: [DEPENDABOT] "
target-branch: develop
groups:
codegen-dependencies:
patterns:
- "*"

View File

@@ -10,4 +10,4 @@ pcpp>=1.30
pyparsing>=3.0.0
# Template engine - used to generate C++ code from templates
Mako>=1.2.2
Mako>=1.4.2

View File

@@ -1,8 +1,8 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in --generate-hashes --output-file requirements.txt
mako==1.3.12 \
--hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \
--hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a
mako==1.4.3 \
--hash=sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f \
--hash=sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a
# via -r requirements.in
markupsafe==3.0.3 \
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \

View File

@@ -1,10 +1,12 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
@@ -17,6 +19,216 @@ namespace xrpl {
class STTx;
/**
* @brief Return the Vault's current assets total.
*
* FixedPrecision Vaults return AssetsAvailable plus AssetsDeployed, with no
* rounding to the asset's 16-digit precision: the sum is exact at Number's
* active 19-digit mantissa width (the large mantissa range, enabled by
* featureSingleAssetVault or featureLendingProtocol), rounds Downward regardless of the
* caller's ambient mode beyond that, and is not rounded to match the cached
* sfAssetsTotal field. Legacy and CashBasis Vaults return the stored
* AssetsTotal.
*
* @param vault The vault SLE.
* @return The current assets total.
*/
[[nodiscard]] Number
getAssetsTotal(SLE::ConstRef vault);
/**
* @brief A change to a FixedPrecision Vault's balances.
*/
struct VaultBalanceChange
{
/**
* Signed change to AssetsAvailable. Must be the exact amount moved on the
* Vault pseudo-account in the same transaction. Absent means no cash
* moved; adjustVaultBalances then uses zero of the Vault's own asset.
*/
std::optional<STAmount> cash = std::nullopt;
/**
* Signed change to AssetsDeployed: open Loan principal, exact.
*/
Number deployed = 0;
/**
* Signed change to scheduled, unpaid interest (YieldUnrealized), exact.
*/
Number yield = 0;
/**
* Signed change to impaired principal (LossUnrealized), exact.
*/
Number loss = 0;
};
/**
* @brief Apply a balance change to a FixedPrecision Vault. The only writer of
* AssetsAvailable, AssetsDeployed, AssetsTotal, YieldUnrealized and
* LossUnrealized on these Vaults after creation.
*
* @param vault The vault SLE. Must be FixedPrecision.
* @param change The balance change to apply.
* @param j Journal for logging the fatal (tefBAD_LEDGER) case.
*
* @return tesSUCCESS; tecLIMIT_EXCEEDED if LossUnrealized would exceed
* AssetsDeployed; tefBAD_LEDGER if AssetsAvailable, AssetsDeployed,
* LossUnrealized or YieldUnrealized would become negative.
*/
[[nodiscard]] TER
adjustVaultBalances(SLE::Ref vault, VaultBalanceChange const& change, beast::Journal j);
/**
* Return the Vault's current live exponent.
*
* Legacy and CashBasis Vaults use the exponent of AssetsTotal. FixedPrecision
* Vaults use max(base exponent, exponent of AssetsTotal): the grid is never
* finer than -Scale and coarsens once AssetsTotal outgrows 16 digits there.
*/
[[nodiscard]] int
getVaultScale(SLE::ConstRef vault);
/**
* Return the Vault's base exponent.
*
* Legacy and CashBasis Vaults use their current live exponent. FixedPrecision
* Vaults use -Scale, or 0 for integral assets.
*/
[[nodiscard]] int
getVaultBaseScale(SLE::ConstRef vault);
/**
* Round an amount at the Vault's posterior live exponent.
*/
[[nodiscard]] STAmount
roundToPosteriorVaultScale(
SLE::ConstRef vault,
STAmount const& amount,
Number::RoundingMode roundingMode);
namespace detail {
/**
* Return the Vault's posterior live exponent after applying an unrounded delta.
*/
[[nodiscard]] int
getPosteriorVaultScale(SLE::ConstRef vault, STAmount const& delta);
/**
* Shared grid-floor core of getVaultScale / getPosteriorVaultScale /
* getPosteriorBrokerCoverScale: baseScale when reference is zero, otherwise
* max(baseScale, scale(reference, asset)), rounded Downward regardless of
* the caller's ambient rounding mode.
*/
[[nodiscard]] int
liveScale(Number const& reference, Asset const& asset, int baseScale);
/**
* Shared core of getPosteriorVaultScale and getPosteriorBrokerCoverScale:
* the exponent reference + delta would have under version's live-scale rule
* (floored at baseScale for FixedPrecision, unbounded for Legacy/CashBasis).
*
* @param version The Vault's LEVersion.
* @param asset The Vault's underlying asset.
* @param baseScale The Vault's base exponent; unused outside the
* FixedPrecision case.
* @param reference The balance the delta is applied to (AssetsAvailable,
* AssetsTotal, or a LoanBroker's CoverAvailable).
* @param delta The unrounded change to reference.
*/
[[nodiscard]] int
posteriorAssetScale(
VaultVersion version,
Asset const& asset,
int baseScale,
Number const& reference,
Number const& delta);
/**
* Shared core of creditToPosteriorAvailableScale and
* creditToPosteriorBrokerCoverScale: floors reference + raw at scale and
* returns the difference from reference, so the delta applied is exactly
* what moves reference onto the floored sum. See
* creditToPosteriorAvailableScale's doc for why the sum, not raw alone,
* must be floored. raw is the exact amount; it is not rounded on its own
* before being added to reference.
*/
[[nodiscard]] STAmount
creditToPosteriorScale(
Asset const& asset,
Number const& reference,
int atScale,
Number const& raw,
Number::RoundingMode roundingMode);
} // namespace detail
/**
* Round a FixedPrecision cash inflow (LoanPay's credit, LoanManage's
* default cover credit) into AssetsAvailable, never AssetsTotal, which is
* a derived cache, by flooring the sum rather than the delta:
* credit equals floor16(AssetsAvailable + raw) minus AssetsAvailable, never
* finer than the Vault's base exponent (-Scale).
*
* Flooring only the delta at its own posterior grid is not enough: the sum
* can still need a 17th digit when AssetsAvailable is off the coarser grid
* right after crossing a power of ten. Example at Scale 6: AssetsAvailable
* equals 9999999999.999999 (16 digits already) and a credit of 0.000011
* floors to 0.00001 on its own (finer) grid, but 9999999999.999999 plus
* 0.00001 equals 10000000000.000009, which still needs 17 digits. Flooring
* the sum instead, floor16(9999999999.999999 + 0.000011) minus
* 9999999999.999999, is exact by construction, since STAmount's own
* 16-digit canonical form of the sum is what gets subtracted from.
*
* raw is taken as an exact Number: rounding it to 16 digits before adding
* it to AssetsAvailable could drop a tail that moves the floored sum.
*/
[[nodiscard]] STAmount
creditToPosteriorAvailableScale(
SLE::ConstRef vault,
Number const& raw,
Number::RoundingMode roundingMode);
/**
* Open-zone capacity ceiling: 9 * 10^(15 + baseScale).
*
* Defined only for FixedPrecision Vaults, where this is 9 * 10^(15 - P).
*/
[[nodiscard]] Number
getVaultOpenLimit(SLE::ConstRef vault);
/**
* Check whether amount is an admissible optional inflow.
*
* Legacy and CashBasis Vaults always succeed. FixedPrecision Vaults must
* remain at their base scale after applying the rounded amount, and the
* posterior capacity (AssetsTotal + YieldUnrealized + rounded amount) must
* stay within the Open zone.
*
* The amount is rounded toward zero at the posterior live exponent.
*/
[[nodiscard]] TER
checkOptionalVaultInflow(SLE::ConstRef vault, STAmount const& amount);
/**
* Open-zone capacity after adding roundedAmount: AssetsTotal + YieldUnrealized
* + roundedAmount, computed TowardsZero regardless of the caller's ambient
* rounding mode. Defined only for FixedPrecision Vaults.
*
* Shared by checkOptionalVaultInflow and LoanSet's loan-origination capacity
* check, which pair this formula with different scale checks (posterior vs.
* current), so only the capacity formula itself -- not the whole check -- is
* shared here.
*
* @param vault The vault SLE. Must be FixedPrecision.
* @param roundedAmount The amount to add, already rounded to the relevant
* posterior or base scale by the caller.
*/
[[nodiscard]] Number
vaultOpenZoneCapacity(SLE::ConstRef vault, Number const& roundedAmount);
/**
* 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
@@ -46,31 +258,81 @@ assetsToSharesDeposit(SLE::ConstRef vault, SLE::ConstRef issuance, STAmount cons
sharesToAssetsDeposit(SLE::ConstRef vault, SLE::ConstRef issuance, STAmount const& shares);
/**
* Adjusts a requested asset change (`delta`) to match the decimal scale of the
* updated total vault assets. This ensures `sfAssetsTotal`, `sfAssetsAvailable`,
* Adjusts a requested asset change (delta) to match the decimal scale of the
* updated total vault assets. This ensures sfAssetsTotal, sfAssetsAvailable,
* and the actual asset transfer change by the exact same representable amount.
*
* Rounding strategy:
* - Debits (withdrawals): Rounds down `|delta|` on the new scale to prevent
* paying out more than requested.
* - Credits (deposits): Floors the resulting total asset balance and returns the
* difference from the current total. This prevents crediting the vault with
* more assets than the user deposited.
* - Legacy/CashBasis debits (withdrawals): rounds down the magnitude of delta
* on the new scale to prevent paying out more than requested.
* - Legacy/CashBasis credits: floors the resulting total asset balance and
* returns the difference from the current total.
* - FixedPrecision credits only (VaultDeposit): rounds the delta toward zero
* at the scale getAssetsTotal (AssetsAvailable + AssetsDeployed, derived,
* not the stored AssetsTotal cache) would have after applying delta.
* FixedPrecision outflows (VaultWithdraw, VaultClawback) never call this;
* they use clampVaultOutflow instead.
*
* Key rules:
* - The returned magnitude never exceeds `|delta|`.
* - Returns `tecPRECISION_LOSS` if the change is smaller than 1 ULP of the target scale
* - The returned magnitude never exceeds the magnitude of delta.
* - Returns tecPRECISION_LOSS if the change is smaller than 1 ULP of the target scale
* (prevents share operations when totals cannot change).
* - For integer assets (XRP, MPT), rounding is a no-op.
*
* @param vault The vault ledger entry.
* @param delta The requested signed change to sfAssetsTotal.
* @return The rounded, positive magnitude, or `tecPRECISION_LOSS` if the
* @return The rounded, positive magnitude, or tecPRECISION_LOSS if the
* change is below representable precision.
*/
[[nodiscard]] std::expected<STAmount, TER>
clampToAssetsTotalScale(SLE::ConstRef vault, STAmount const& delta);
/**
* Outflow clamp dispatcher for VaultWithdraw and VaultClawback: rounds a
* negative delta to the scale appropriate for the Vault's version -- for
* FixedPrecision, at AssetsAvailable's own posterior scale (the balance the
* cash actually leaves); for Legacy/CashBasis, clampToAssetsTotalScale --
* so both call sites share one dispatch point. Same tecPRECISION_LOSS and
* integral-asset and magnitude-never-exceeds-delta rules as
* clampToAssetsTotalScale.
*
* @param vault The vault ledger entry.
* @param delta The requested signed outflow (negative).
* @return The rounded, positive magnitude, or tecPRECISION_LOSS if the
* change is below representable precision.
*/
[[nodiscard]] std::expected<STAmount, TER>
clampVaultOutflow(SLE::ConstRef vault, STAmount const& delta);
/**
* Returns the Vault's base exponent for asset at scale, before a Vault
* object exists to read it from: 0 for an integral asset, -scale otherwise.
* Used by VaultCreate::preclaim, which only has the proposed Scale field,
* not yet a Vault SLE; getVaultBaseScale is the post-creation equivalent.
*
* @param asset The (prospective) Vault's underlying asset.
* @param scale The (prospective) Vault's sfScale value, ignored for
* integral assets.
*/
[[nodiscard]] int
vaultBaseScale(Asset const& asset, std::uint8_t scale);
/**
* Returns true iff value is exactly representable both as an STAmount of
* asset and on the base grid at baseScale (no precision lost rounding
* TowardsZero at baseScale). Returns false, rather than throwing, for a value
* out of range for the asset. Shared representability check for
* VaultCreate::preclaim, VaultSet::preclaim and LoanBrokerSet::preclaim's
* AssetsMaximum / DebtMaximum / cover-rate field checks.
*
* @param asset The vault's underlying asset.
* @param value The value to check.
* @param baseScale The vault's base exponent (see vaultBaseScale /
* getVaultBaseScale).
*/
[[nodiscard]] bool
isOnVaultBaseGrid(Asset const& asset, Number const& value, int baseScale);
/**
* Controls whether to truncate shares instead of rounding.
*/
@@ -98,9 +360,9 @@ enum class WaiveUnrealizedLoss : bool { No = false, Yes = true };
assetsTotalForWithdrawal(SLE::ConstRef vault, WaiveUnrealizedLoss waive);
/**
* Returns true if debiting `amount` from `total` (the current value of a
* Returns true if debiting amount from total (the current value of a
* vault's sfAssetsTotal or sfAssetsAvailable) would canonicalize to the
* same STAmount value. This happens when `amount` is non-zero but too small
* same STAmount value. This happens when amount is non-zero but too small
* to change the stored total at STAmount's precision. Shares would still
* move, so the ValidVault invariant would fail after apply; callers use
* this to reject the transaction upfront instead.
@@ -114,6 +376,20 @@ assetsTotalForWithdrawal(SLE::ConstRef vault, WaiveUnrealizedLoss waive);
[[nodiscard]] bool
debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount);
/**
* Returns the reference balance a cash outflow's dust check
* (debitIsNonZeroDust) should compare against: FixedPrecision Vaults use
* AssetsAvailable, the balance the cash actually leaves; Legacy/CashBasis
* Vaults use the given AssetsTotal. Shared by VaultWithdraw and
* VaultClawback.
*
* @param vault The vault SLE.
* @param assetsTotal The vault's current AssetsTotal, as already read by the
* caller.
*/
[[nodiscard]] Number
vaultDebitDustReference(SLE::ConstRef vault, Number const& assetsTotal);
/**
* From the perspective of a vault, return the number of shares to demand from
* the depositor when they ask to withdraw a fixed amount of assets. Since
@@ -158,7 +434,7 @@ sharesToAssetsWithdraw(
WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No);
/**
* Returns true iff `account` holds all of the vault's outstanding shares —
* Returns true iff account holds all of the vault's outstanding shares,
* i.e. is the sole remaining shareholder. Returns false if the account
* holds no shares or fewer than the total outstanding.
*
@@ -171,11 +447,13 @@ sharesToAssetsWithdraw(
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::ConstRef issuance);
/**
* Resolves a Vault's LEVersion, the single point every accounting touch
* point should call to determine which recognition model (instant interest
* recognition vs. cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1
* activated never have sfLEVersion set, which resolves here to
* VaultVersion::Legacy.
* Resolves a Vault's LEVersion.
*
* LEVersion is the single point every accounting and rounding helper
* should call to decide which protocol a Vault follows. It is written
* at VaultCreate and is not updated afterwards, so a Vault created
* under an older amendment keeps that behaviour after later amendments
* activate. Absent sfLEVersion resolves to VaultVersion::Legacy.
*
* @param vault The vault SLE.
*
@@ -185,6 +463,33 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::ConstRef
[[nodiscard]] VaultVersion
getVaultVersion(SLE::ConstRef vault);
/**
* Decodes an already-extracted sfLEVersion value with the same range check as
* getVaultVersion. Usable from contexts, such as an invariant's ledger-entry
* snapshot, that keep the field value but not the owning SLE.
*
* @param leVersion The value of sfLEVersion, or nullopt if absent.
*
* @return The decoded LEVersion, or VaultVersion::Legacy if absent.
*/
[[nodiscard]] VaultVersion
decodeVaultVersion(std::optional<std::uint8_t> leVersion);
/**
* Resolves which LEVersion a newly-created Vault should get under the
* currently active amendments. This is the only place that decides Vault
* version policy; VaultCreate calls it instead of checking amendments
* directly.
*
* @param rules The active ledger rules.
*
* @return VaultVersion::FixedPrecision once featureLendingProtocolV1_2,
* fixCleanup3_2_0 and fixCleanup3_4_0 are all enabled; VaultVersion::CashBasis
* once featureLendingProtocolV1_1 is enabled; VaultVersion::Legacy otherwise.
*/
[[nodiscard]] VaultVersion
getVaultDefaultVersion(Rules const& rules);
/**
* Resolves the VaultKind of a vault SLE. Returns VaultKind::ClosedEnded when
* sfVaultKind is present and equal to that value; anything else (including an

View File

@@ -311,24 +311,46 @@ constexpr std::uint8_t kVaultStrategyFirstComeFirstServe = 1;
* Default IOU scale factor for a Vault
*/
constexpr std::uint8_t kVaultDefaultIouScale = 6;
/**
* Maximum scale factor for a Vault. The number is chosen to ensure that
* 1 IOU can be always converted to shares.
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18
* Maximum Scale for a Vault created before featureLendingProtocolV1_2.
* Chosen so 1 IOU can always convert to shares:
* 10^19 > maxMPTokenAmount (2^64-1) > 10^18.
*/
constexpr std::uint8_t kVaultMaximumIouScale = 18;
/**
* Vault ledger-entry schema versions. Assigned to newly created
* Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before
* activation are left without LEVersion (implicit legacy version 0,
* instant interest recognition).
* Maximum Scale for a Vault created under featureLendingProtocolV1_2.
*/
constexpr std::uint8_t kVaultMaximumFixedPrecisionIouScale = 10;
/**
* Vault ledger-entry schema versions, persisted as sfLEVersion.
*
* LEVersion records which protocol a Vault was created under so later
* amendments can change the rules for new Vaults without rewriting
* existing ones. VaultCreate writes it from the then-active lending
* amendments; later transactions do not update it. A Vault created
* under an older amendment keeps that amendment's behaviour for its
* lifetime, even after a newer lending amendment activates.
*
* Absent sfLEVersion is implicit Legacy (version 0): instant interest
* recognition and a dynamic AssetsTotal scale. CashBasis (V1.1) uses
* cash-basis recognition on the same dynamic scale. FixedPrecision
* (V1.2) keeps cash-basis recognition and adds the lifetime base grid.
*/
enum class VaultVersion : uint8_t {
Legacy = 0,
CashBasis,
FixedPrecision,
};
// Code compares VaultVersion values with < and >= (e.g. "CashBasis or later"),
// so each later version must stay numerically larger than the one before.
static_assert(
VaultVersion::Legacy < VaultVersion::CashBasis &&
VaultVersion::CashBasis < VaultVersion::FixedPrecision);
/**
* Vault kind. Distinguishes closed-ended vaults from the default open-ended
* kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded.

View File

@@ -17,6 +17,7 @@
XRPL_FEATURE(Cosign, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo)
// Requires LendingProtocolV1_1. New vaults take FixedPrecision plus cash-basis.
XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(ConfidentialMPTKeyRotation, Supported::No, VoteBehavior::DefaultNo)

View File

@@ -514,6 +514,8 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
{sfAssetsAvailable, SoeDefault},
{sfAssetsMaximum, SoeDefault},
{sfLossUnrealized, SoeDefault},
{sfYieldUnrealized, SoeDefault},
{sfAssetsDeployed, SoeDefault},
{sfShareMPTID, SoeRequired},
{sfWithdrawalPolicy, SoeRequired},
{sfScale, SoeDefault},

View File

@@ -240,6 +240,8 @@ 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(sfYieldUnrealized, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfAssetsDeployed, NUMBER, 19, SField::kSmdNeedsAsset | SField::kSmdDefault)
// 32-bit signed (common)
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -242,6 +242,54 @@ public:
return this->sle_->isFieldPresent(sfLossUnrealized);
}
/**
* @brief Get sfYieldUnrealized (SoeDefault)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_NUMBER::type::value_type>
getYieldUnrealized() const
{
if (hasYieldUnrealized())
return this->sle_->at(sfYieldUnrealized);
return std::nullopt;
}
/**
* @brief Check if sfYieldUnrealized is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasYieldUnrealized() const
{
return this->sle_->isFieldPresent(sfYieldUnrealized);
}
/**
* @brief Get sfAssetsDeployed (SoeDefault)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_NUMBER::type::value_type>
getAssetsDeployed() const
{
if (hasAssetsDeployed())
return this->sle_->at(sfAssetsDeployed);
return std::nullopt;
}
/**
* @brief Check if sfAssetsDeployed is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasAssetsDeployed() const
{
return this->sle_->isFieldPresent(sfAssetsDeployed);
}
/**
* @brief Get sfShareMPTID (SoeRequired)
* @return The field value.
@@ -571,6 +619,28 @@ public:
return *this;
}
/**
* @brief Set sfYieldUnrealized (SoeDefault)
* @return Reference to this builder for method chaining.
*/
VaultBuilder&
setYieldUnrealized(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfYieldUnrealized] = value;
return *this;
}
/**
* @brief Set sfAssetsDeployed (SoeDefault)
* @return Reference to this builder for method chaining.
*/
VaultBuilder&
setAssetsDeployed(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfAssetsDeployed] = value;
return *this;
}
/**
* @brief Set sfShareMPTID (SoeRequired)
* @return Reference to this builder for method chaining.

View File

@@ -1,12 +1,16 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -17,6 +21,7 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <algorithm>
#include <cstdint>
#include <expected>
#include <optional>
@@ -24,6 +29,405 @@
namespace xrpl {
namespace {
[[nodiscard]] int
fixedBaseScale(SLE::ConstRef vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::fixedBaseScale : valid Vault sle");
Asset const asset = vault->at(sfAsset);
// sfScale is never written for an integral asset (VaultCreate only sets
// it when scale != 0, and integral assets always get scale 0), so it
// must not be read here; vaultBaseScale ignores the scale argument for
// an integral asset anyway.
if (asset.integral())
return 0;
return vaultBaseScale(asset, vault->at(sfScale));
}
// Thin, vault-specific wrapper over posteriorAssetScale: used by
// getPosteriorVaultScale and creditToPosteriorAvailableScale.
[[nodiscard]] int
posteriorScale(SLE::ConstRef vault, Number const& reference, Number const& delta)
{
return detail::posteriorAssetScale(
getVaultVersion(vault), vault->at(sfAsset), fixedBaseScale(vault), reference, delta);
}
// FixedPrecision-only counterpart to clampToAssetsTotalScale, for cash
// outflows (VaultWithdraw, VaultClawback): rounds the magnitude of delta
// toward zero at AssetsAvailable's own posterior scale, the balance the cash
// actually leaves, instead of the derived AssetsTotal cache. Same
// tecPRECISION_LOSS and integral-asset and magnitude-never-exceeds-delta
// rules as clampToAssetsTotalScale. Not part of the public interface; reached
// only through clampVaultOutflow.
//
// For an outflow it is safe to round the delta directly, unlike the credit
// case: amount on the posterior grid of AssetsAvailable always leaves
// AssetsAvailable minus amount representable, since the posterior grid is
// exactly the grid AssetsAvailable itself will canonicalize to after the
// subtraction.
[[nodiscard]] std::expected<STAmount, TER>
clampToAvailableScale(SLE::ConstRef vault, STAmount const& delta)
{
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::clampToAvailableScale : delta and vault asset match");
XRPL_ASSERT(
getVaultVersion(vault) == VaultVersion::FixedPrecision,
"xrpl::clampToAvailableScale : FixedPrecision Vault");
XRPL_ASSERT(delta.negative(), "xrpl::clampToAvailableScale : outflow delta is negative");
Asset const asset = vault->at(sfAsset);
STAmount magnitude = delta.negative() ? -delta : delta;
if (asset.integral())
return magnitude;
STAmount const rounded = roundToScale(
delta,
posteriorScale(vault, vault->at(sfAssetsAvailable), delta),
Number::RoundingMode::TowardsZero);
STAmount actualDelta = rounded.negative() ? -rounded : rounded;
XRPL_ASSERT(
abs(actualDelta) <= abs(delta),
"xrpl::clampToAvailableScale : actual delta smaller or equal to calculated delta");
// Reject changes below scale precision (1 ULP) to prevent share balance changes
// without corresponding asset movements.
if (actualDelta <= beast::kZero)
return std::unexpected(tecPRECISION_LOSS);
return actualDelta;
}
[[nodiscard]] VaultKind
decodeVaultKind(std::optional<std::uint8_t> vaultKind)
{
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
return VaultKind::ClosedEnded;
return VaultKind::OpenEnded;
}
// Write cached AssetsTotal from getAssetsTotal, as the exact total rounded
// Downward to the Vault asset's 16-digit STAmount representation. Below
// coarsening this equals the exact total; above it, the cache is a
// deliberate floor of the exact value. Internal to adjustVaultBalances,
// the only writer of AssetsAvailable, AssetsDeployed, AssetsTotal,
// YieldUnrealized and LossUnrealized on a FixedPrecision Vault after
// creation; not part of the public interface.
void
syncAssetsTotal(SLE::Ref vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::syncAssetsTotal : valid Vault sle");
Asset const asset = vault->at(sfAsset);
// adjustVaultBalances writes AssetsAvailable as an already-canonical
// STAmount before calling this, so the associateAsset pass a transactor
// runs afterwards is always a no-op. If this fires, AssetsAvailable was
// written some other way.
XRPL_ASSERT(
(STAmount{asset, vault->at(sfAssetsAvailable)} == vault->at(sfAssetsAvailable)),
"xrpl::syncAssetsTotal : AssetsAvailable is a 16-digit STAmount value");
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
vault->at(sfAssetsTotal) = STAmount{asset, getAssetsTotal(vault)};
}
} // namespace
namespace detail {
[[nodiscard]] int
liveScale(Number const& reference, Asset const& asset, int baseScale)
{
if (reference == beast::kZero)
return baseScale;
// Round Downward, not the ambient mode: the live exponent must match the
// exponent syncAssetsTotal actually stores (also Downward), regardless of
// what rounding mode the caller's own arithmetic is using.
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return std::max(baseScale, scale(reference, asset));
}
[[nodiscard]] int
posteriorAssetScale(
VaultVersion version,
Asset const& asset,
int baseScale,
Number const& reference,
Number const& delta)
{
// ToNearest for the sum and for scale()'s canonicalization of it, as the
// Legacy/CashBasis clamp always did; liveScale sets its own mode.
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
Number const posterior = reference + delta;
switch (version)
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(posterior, asset);
case VaultVersion::FixedPrecision:
return liveScale(posterior, asset, baseScale);
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::detail::posteriorAssetScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] STAmount
creditToPosteriorScale(
Asset const& asset,
Number const& reference,
int atScale,
Number const& raw,
Number::RoundingMode roundingMode)
{
// Floor the SUM (reference + raw), not just raw, at atScale. See
// creditToPosteriorAvailableScale's doc: a delta floored on its own grid
// can still leave a 17-digit sum once the reference has crossed a power
// of ten.
//
// Both reference + raw and flooredSum - reference can carry more digits
// than Number holds (the sum is on the posterior grid, the reference on
// the finer one). Compute both under roundingMode, not the caller's
// ambient mode, so the credit this returns never exceeds raw.
NumberRoundModeGuard const rg(roundingMode);
Number const flooredSum = roundToAsset(asset, reference + raw, atScale, roundingMode);
return STAmount{asset, flooredSum - reference};
}
[[nodiscard]] int
getPosteriorVaultScale(SLE::ConstRef vault, STAmount const& delta)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::detail::getPosteriorVaultScale : valid Vault sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::detail::getPosteriorVaultScale : delta and Vault asset match");
return posteriorScale(vault, getAssetsTotal(vault), delta);
}
} // namespace detail
[[nodiscard]] Number
getAssetsTotal(SLE::ConstRef vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getAssetsTotal : valid Vault sle");
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return vault->at(sfAssetsTotal);
// AssetsAvailable is at -Scale (or coarser) and AssetsDeployed is always at
// -Scale, so the sum is exact while it fits 19 digits. Fix the mode so a
// sum that does not fit rounds the same way for every caller.
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return vault->at(sfAssetsAvailable) + vault->at(sfAssetsDeployed);
}
[[nodiscard]] TER
adjustVaultBalances(SLE::Ref vault, VaultBalanceChange const& change, beast::Journal j)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::adjustVaultBalances : valid Vault sle");
XRPL_ASSERT(
getVaultVersion(vault) == VaultVersion::FixedPrecision,
"xrpl::adjustVaultBalances : FixedPrecision Vault");
Asset const asset = vault->at(sfAsset);
STAmount const cash = change.cash.value_or(STAmount{asset, 0});
XRPL_ASSERT(cash.asset() == asset, "xrpl::adjustVaultBalances : cash and Vault asset match");
// This is the mode the ledger's own transfers (trust line / MPT balance
// updates) normalize in; computing AA' with any other ambient mode would
// not reproduce what the real transfer just did to the backing balance.
XRPL_ASSERT(
Number::getround() == Number::RoundingMode::ToNearest,
"xrpl::adjustVaultBalances : ambient rounding mode is ToNearest");
// AA' is the exact trust-line/MPT arithmetic the ledger uses for the
// transfer itself: a Number sum re-canonicalized to the asset's 16-digit
// STAmount precision. MPT and XRP sums are already integral, so this is
// a no-op rounding for them.
//
// AssetsDeployed, LossUnrealized and YieldUnrealized move no cash, so they stay
// exact Number sums; the asserts below check each fits 16 digits, rather
// than letting an STAmount conversion round it silently.
STAmount const availableAfter{asset, Number(vault->at(sfAssetsAvailable)) + Number(cash)};
Number const assetsDeployedAfter = Number(vault->at(sfAssetsDeployed)) + change.deployed;
Number const lossUnrealizedAfter = Number(vault->at(sfLossUnrealized)) + change.loss;
// LoanSet, LoanPay and default each move YieldUnrealized by the change in
// the Loan's own TotalValueOutstanding - PrincipalOutstanding -
// ManagementFeeOutstanding, so it always equals that sum over the
// Vault's Loans and cannot go negative.
Number const yieldUnrealizedAfter = Number(vault->at(sfYieldUnrealized)) + change.yield;
if (availableAfter < beast::kZero || assetsDeployedAfter < beast::kZero ||
lossUnrealizedAfter < beast::kZero || yieldUnrealizedAfter < beast::kZero)
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "adjustVaultBalances: a balance would become negative."
<< " AssetsAvailable: " << Number(availableAfter)
<< ", AssetsDeployed: " << assetsDeployedAfter
<< ", LossUnrealized: " << lossUnrealizedAfter
<< ", YieldUnrealized: " << yieldUnrealizedAfter;
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
// The impairment rule: a Loan can only be impaired for as much as it
// still owes. Reachable only through LoanManage's impair.
if (lossUnrealizedAfter > assetsDeployedAfter)
return tecLIMIT_EXCEEDED;
// Every caller derives principal from a Loan's own PrincipalOutstanding so the new total must
// stay representable at the asset's own 16-digit precision too.
XRPL_ASSERT(
(STAmount{asset, assetsDeployedAfter} == assetsDeployedAfter),
"xrpl::adjustVaultBalances : AssetsDeployed is a 16-digit STAmount value");
XRPL_ASSERT(
(STAmount{asset, lossUnrealizedAfter} == lossUnrealizedAfter),
"xrpl::adjustVaultBalances : LossUnrealized is a 16-digit STAmount value");
XRPL_ASSERT(
(STAmount{asset, yieldUnrealizedAfter} == yieldUnrealizedAfter),
"xrpl::adjustVaultBalances : YieldUnrealized is a 16-digit STAmount value");
vault->at(sfAssetsAvailable) = availableAfter;
vault->at(sfAssetsDeployed) = assetsDeployedAfter;
vault->at(sfLossUnrealized) = lossUnrealizedAfter;
vault->at(sfYieldUnrealized) = yieldUnrealizedAfter;
syncAssetsTotal(vault);
return tesSUCCESS;
}
[[nodiscard]] int
getVaultScale(SLE::ConstRef vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultScale : valid Vault sle");
Number const assetsTotal = getAssetsTotal(vault);
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(assetsTotal, vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return detail::liveScale(assetsTotal, vault->at(sfAsset), fixedBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] int
getVaultBaseScale(SLE::ConstRef vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultBaseScale : valid Vault sle");
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return getVaultScale(vault);
case VaultVersion::FixedPrecision:
return fixedBaseScale(vault);
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultBaseScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] STAmount
roundToPosteriorVaultScale(
SLE::ConstRef vault,
STAmount const& amount,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::roundToPosteriorVaultScale : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::roundToPosteriorVaultScale : amount and Vault asset match");
if (amount.integral())
return amount;
return roundToScale(amount, detail::getPosteriorVaultScale(vault, amount), roundingMode);
}
[[nodiscard]] STAmount
creditToPosteriorAvailableScale(
SLE::ConstRef vault,
Number const& raw,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::creditToPosteriorAvailableScale : valid Vault sle");
Asset const asset = vault->at(sfAsset);
if (asset.integral())
{
NumberRoundModeGuard const rg(roundingMode);
return STAmount{asset, raw};
}
Number const reference = vault->at(sfAssetsAvailable);
int const scale = posteriorScale(vault, reference, raw);
return detail::creditToPosteriorScale(asset, reference, scale, raw, roundingMode);
}
[[nodiscard]] Number
getVaultOpenLimit(SLE::ConstRef vault)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultOpenLimit : valid Vault sle");
XRPL_ASSERT(
getVaultVersion(vault) == VaultVersion::FixedPrecision,
"xrpl::getVaultOpenLimit : FixedPrecision Vault");
return Number{9, 15 + getVaultBaseScale(vault)};
}
[[nodiscard]] TER
checkOptionalVaultInflow(SLE::ConstRef vault, STAmount const& amount)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::checkOptionalVaultInflow : valid Vault sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::checkOptionalVaultInflow : amount and Vault asset match");
XRPL_ASSERT(!amount.negative(), "xrpl::checkOptionalVaultInflow : non-negative amount");
if (amount.negative())
return tefINTERNAL; // LCOV_EXCL_LINE
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return tesSUCCESS;
STAmount const rounded =
roundToPosteriorVaultScale(vault, amount, Number::RoundingMode::TowardsZero);
int const baseScale = getVaultBaseScale(vault);
// Keep this explicit even though a non-negative YieldUnrealized makes the
// Open-zone capacity ceiling reject every coarsening transition too. The
// protocol defines both conditions independently.
if (detail::getPosteriorVaultScale(vault, rounded) != baseScale)
return tecLIMIT_EXCEEDED;
if (vaultOpenZoneCapacity(vault, rounded) > getVaultOpenLimit(vault))
return tecLIMIT_EXCEEDED;
return tesSUCCESS;
}
[[nodiscard]] Number
vaultOpenZoneCapacity(SLE::ConstRef vault, Number const& roundedAmount)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::vaultOpenZoneCapacity : valid Vault sle");
XRPL_ASSERT(
getVaultVersion(vault) == VaultVersion::FixedPrecision,
"xrpl::vaultOpenZoneCapacity : FixedPrecision Vault");
NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero);
return getAssetsTotal(vault) + vault->at(sfYieldUnrealized) + roundedAmount;
}
[[nodiscard]] std::optional<STAmount>
assetsToSharesDeposit(SLE::ConstRef vault, SLE::ConstRef issuance, STAmount const& assets)
{
@@ -34,7 +438,7 @@ assetsToSharesDeposit(SLE::ConstRef vault, SLE::ConstRef issuance, STAmount cons
if (assets.negative() || assets.asset() != vault->at(sfAsset))
return std::nullopt; // LCOV_EXCL_LINE
Number const assetTotal = vault->at(sfAssetsTotal);
Number const assetTotal = getAssetsTotal(vault);
STAmount shares{vault->at(sfShareMPTID)};
if (assetTotal == 0)
{
@@ -58,7 +462,7 @@ sharesToAssetsDeposit(SLE::ConstRef vault, SLE::ConstRef issuance, STAmount cons
if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
return std::nullopt; // LCOV_EXCL_LINE
Number const assetTotal = vault->at(sfAssetsTotal);
Number const assetTotal = getAssetsTotal(vault);
STAmount assets{vault->at(sfAsset)};
if (assetTotal == 0)
{
@@ -85,37 +489,52 @@ clampToAssetsTotalScale(SLE::ConstRef vault, STAmount const& delta)
{
return magnitude;
}
Number const assetsTotal = vault->at(sfAssetsTotal);
// Calculate the scale after applying the delta using ToNearest rounding.
// This aligns the delta with scale checks used by vault invariants.
int const postScale = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(assetsTotal + delta, asset);
}();
STAmount actualDelta;
if (delta.negative())
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
// For withdrawals (debits), floor the magnitude to the target scale
// to ensure exact grid alignment without paying out extra assets.
actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
// FixedPrecision only reaches this branch for credits (VaultDeposit);
// outflows go through clampVaultOutflow -> clampToAvailableScale,
// which rounds at AssetsAvailable's own posterior scale instead.
XRPL_ASSERT(
!delta.negative(), "xrpl::clampToAssetsTotalScale : FixedPrecision credit only");
STAmount const rounded =
roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero);
actualDelta = rounded.negative() ? -rounded : rounded;
}
else
{
// For deposits (credits), derive actualDelta from the floored posterior total.
// This prevents grid alignment issues from crediting the vault more than deposited.
//
// Sum using Downward rounding so intermediate precision doesn't round up
// and exceed the original requested amount.
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return assetsTotal + magnitude;
Number const assetsTotal = getAssetsTotal(vault);
// Calculate the scale after applying the delta using ToNearest rounding.
// This aligns the delta with scale checks used by vault invariants.
int const postScale = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(assetsTotal + delta, asset);
}();
Number const roundedPosterior =
roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
if (delta.negative())
{
// For withdrawals (debits), floor the magnitude to the target scale
// to ensure exact grid alignment without paying out extra assets.
actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward);
}
else
{
// For deposits (credits), derive actualDelta from the floored posterior total.
// This prevents grid alignment issues from crediting the vault more than deposited.
//
// Sum using Downward rounding so intermediate precision doesn't round up
// and exceed the original requested amount.
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return assetsTotal + magnitude;
}();
Number const roundedPosterior =
roundToAsset(asset, posterior, postScale, Number::RoundingMode::Downward);
actualDelta = STAmount{asset, roundedPosterior - assetsTotal};
}
}
XRPL_ASSERT(
@@ -130,10 +549,36 @@ clampToAssetsTotalScale(SLE::ConstRef vault, STAmount const& delta)
return actualDelta;
}
[[nodiscard]] std::expected<STAmount, TER>
clampVaultOutflow(SLE::ConstRef vault, STAmount const& delta)
{
XRPL_ASSERT(delta.negative(), "xrpl::clampVaultOutflow : outflow delta is negative");
return getVaultVersion(vault) == VaultVersion::FixedPrecision
? clampToAvailableScale(vault, delta)
: clampToAssetsTotalScale(vault, delta);
}
[[nodiscard]] int
vaultBaseScale(Asset const& asset, std::uint8_t scale)
{
if (asset.integral())
return 0;
return -static_cast<int>(scale);
}
[[nodiscard]] bool
isOnVaultBaseGrid(Asset const& asset, Number const& value, int baseScale)
{
// isRepresentable first: building an STAmount from an out-of-range value
// throws, and this checks user-supplied values in preclaim.
return isRepresentable(asset, value) && STAmount{asset, value} == value &&
roundToAsset(asset, value, baseScale, Number::RoundingMode::TowardsZero) == value;
}
[[nodiscard]] Number
assetsTotalForWithdrawal(SLE::ConstRef vault, WaiveUnrealizedLoss waive)
{
Number assetTotal = vault->at(sfAssetsTotal);
Number assetTotal = getAssetsTotal(vault);
if (waive == WaiveUnrealizedLoss::No)
assetTotal -= vault->at(sfLossUnrealized);
return assetTotal;
@@ -147,6 +592,14 @@ debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount
return STAmount{asset, total - amount} == STAmount{asset, total};
}
[[nodiscard]] Number
vaultDebitDustReference(SLE::ConstRef vault, Number const& assetsTotal)
{
return getVaultVersion(vault) == VaultVersion::FixedPrecision
? Number(vault->at(sfAssetsAvailable))
: assetsTotal;
}
[[nodiscard]] std::optional<STAmount>
assetsToSharesWithdraw(
SLE::ConstRef vault,
@@ -218,34 +671,39 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::ConstRef
}
[[nodiscard]] VaultVersion
getVaultVersion(SLE::ConstRef vault)
decodeVaultVersion(std::optional<std::uint8_t> leVersion)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle");
if (!vault->isFieldPresent(sfLEVersion))
if (!leVersion)
return VaultVersion::Legacy;
auto const version = vault->at(sfLEVersion);
if (version > std::to_underlying(VaultVersion::CashBasis))
if (*leVersion > std::to_underlying(VaultVersion::FixedPrecision))
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::getVaultVersion : invalid vault version");
UNREACHABLE("xrpl::decodeVaultVersion : invalid vault version");
return VaultVersion::Legacy;
// LCOV_EXCL_STOP
}
return static_cast<VaultVersion>(version);
return static_cast<VaultVersion>(*leVersion);
}
namespace {
[[nodiscard]] VaultKind
decodeVaultKind(std::optional<std::uint8_t> vaultKind)
[[nodiscard]] VaultVersion
getVaultVersion(SLE::ConstRef vault)
{
if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded))
return VaultKind::ClosedEnded;
return VaultKind::OpenEnded;
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle");
return decodeVaultVersion(vault->at(~sfLEVersion));
}
} // namespace
[[nodiscard]] VaultVersion
getVaultDefaultVersion(Rules const& rules)
{
// FixedPrecision requires both cleanups, so a FixedPrecision Vault always
// sees them enabled.
if (rules.enabled(featureLendingProtocolV1_2) && rules.enabled(fixCleanup3_2_0) &&
rules.enabled(fixCleanup3_4_0))
return VaultVersion::FixedPrecision;
if (rules.enabled(featureLendingProtocolV1_1))
return VaultVersion::CashBasis;
return VaultVersion::Legacy;
}
[[nodiscard]] VaultKind
getVaultKind(SLE::ConstRef vault)

View File

@@ -8,6 +8,7 @@
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STIssue.h>
@@ -20,17 +21,19 @@
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <memory>
#include <optional>
#include <string>
#include <utility>
namespace xrpl {
// True unit test of `clampToAssetsTotalScale`. The function under test only
// reads sfAsset and sfAssetsTotal from the vault SLE and never touches a
// ledger view or Rules, so a bare in-memory ltVAULT SLE is enough; there is
// no jtx::Env and no transaction submitted anywhere in this file.
// True unit test of clampToAssetsTotalScale. The function under test reads
// only fields from the vault SLE and never touches a ledger view or Rules, so
// a bare in-memory ltVAULT SLE is enough; there is no jtx::Env and no
// transaction submitted anywhere in this file.
//
// Number regime: this suite relies on the default thread_local Number
// mantissa range, which src/libxrpl/basics/Number.cpp initializes to
@@ -39,17 +42,17 @@ namespace xrpl {
// thread_local std::reference_wrapper<MantissaRange const> Number::kRange =
// MantissaRange::Access::mantissaRange(MantissaRange::MantissaScale::Large330);
//
// Unlike transaction processing, this test never constructs a ledger `Rules`
// object, so `STAmount::operator=(Number const&)` always takes its
// `!getCurrentTransactionRules()` branch and calls `fromNumber`, independent
// Unlike transaction processing, this test never constructs a ledger Rules
// object, so STAmount::operator=(Number const&) always takes its
// !getCurrentTransactionRules() branch and calls fromNumber, independent
// of amendment state. testProbeLarge330Regime() below asserts directly on a
// value that only round-trips exactly under Large330, pinning the regime
// rather than merely asserting it by comment.
class VaultHelpers_test : public beast::unit_test::Suite
{
private:
// A single row of the clampToAssetsTotalScale table. `assetsTotal` and
// `delta` must already be genuine, on-grid STAmount values for `asset`.
// A single row of the clampToAssetsTotalScale table. assetsTotal and
// delta must already be genuine, on-grid STAmount values for asset.
struct Case
{
char const* name = nullptr;
@@ -58,27 +61,44 @@ private:
std::optional<Number> expected; // nullopt means tecPRECISION_LOSS
};
// Builds a bare ltVAULT SLE with only sfAsset and sfAssetsTotal set,
// mirroring what a transactor does: set the STNumber field, then call
// associateAsset() so it is quantized to the asset's STAmount grid, the
// same way VaultDeposit::doApply does for a real vault (see
// src/libxrpl/tx/transactors/vault/VaultDeposit.cpp).
// Builds a bare ltVAULT SLE. With fixedScale absent, sfLEVersion stays
// absent too, preserving the pre-V1.2 Legacy behavior exercised by the
// existing clamp table. With fixedScale set, the SLE is stamped
// FixedPrecision with that Scale, exercising clampToAssetsTotalScale's
// roundToPosteriorVaultScale branch instead.
static std::shared_ptr<SLE>
makeVault(Asset const& asset, Number const& assetsTotal)
makeVault(
Asset const& asset,
Number const& assetsTotal,
std::optional<std::uint8_t> fixedScale = std::nullopt)
{
auto vault = std::make_shared<SLE>(keylet::vault(UInt256(1)));
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfAssetsTotal) = assetsTotal;
associateAsset(*vault, asset);
if (fixedScale)
{
vault->at(sfLEVersion) = std::to_underlying(VaultVersion::FixedPrecision);
vault->at(sfScale) = *fixedScale;
// FixedPrecision derives its total from these two fields.
vault->at(sfAssetsAvailable) = assetsTotal;
vault->at(sfAssetsDeployed) = Number{0};
}
return vault;
}
// Runs every case in `cases` against `asset`, once per ambient rounding
// Runs every case in cases against asset, once per ambient rounding
// mode. The function must give the same answer under all four modes,
// and its answer must match the hand-derived `expected` value.
// and its answer must match the hand-derived expected value.
// fixedScale, when set, builds a FixedPrecision vault at that Scale
// instead of the default Legacy vault.
template <std::size_t N>
void
runCases(Asset const& asset, std::array<Case, N> const& cases)
runCases(
Asset const& asset,
std::array<Case, N> const& cases,
std::optional<std::uint8_t> fixedScale = std::nullopt,
bool viaClampVaultOutflow = false)
{
std::array<Number::RoundingMode, 4> const modes{
Number::RoundingMode::ToNearest,
@@ -91,7 +111,7 @@ private:
{
testcase(c.name);
auto const vault = makeVault(asset, c.assetsTotal);
auto const vault = makeVault(asset, c.assetsTotal, fixedScale);
BEAST_EXPECTS(
Number(vault->at(sfAssetsTotal)) == c.assetsTotal,
std::string(c.name) +
@@ -107,7 +127,8 @@ private:
for (auto const mode : modes)
{
NumberRoundModeGuard const rg(mode);
auto const result = clampToAssetsTotalScale(vault, delta);
auto const result = viaClampVaultOutflow ? clampVaultOutflow(vault, delta)
: clampToAssetsTotalScale(vault, delta);
// The function must be insensitive to the caller's ambient
// rounding mode: every mode must agree with the first one
@@ -151,7 +172,13 @@ private:
continue;
}
STAmount const expected{asset, *c.expected};
// c.expected is guaranteed set here: the !c.expected case
// above always continues to the next loop iteration.
STAmount const expected{
asset,
// NOLINTNEXTLINE(bugprone-unchecked-optional-access)
*c.expected,
};
if (!BEAST_EXPECTS(
result.has_value(),
std::string(c.name) + ": expected success (" + expected.getText() +
@@ -176,9 +203,9 @@ private:
// representable at that scale.
//
// For debits this holds directly at postScale, because the
// result IS `roundToScale(magnitude, postScale, Downward)` by
// result IS roundToScale(magnitude, postScale, Downward) by
// construction. For credits the result is
// `roundedPosterior - assetsTotal`, where roundedPosterior
// roundedPosterior - assetsTotal, where roundedPosterior
// sits exactly on the postScale grid but assetsTotal sits on
// its own (possibly finer) natural grid; the difference of a
// multiple of 10^postScale and a multiple of 10^assetsScale
@@ -347,7 +374,7 @@ private:
// = 4.999999999999991.
// This mirrors testBugVaultDepositOvercreditsAcrossScaleBoundary
// in VaultBugs_test.cpp (same seed/deposit values), which
// asserts post-fix `credited <= paid` rather than an exact
// asserts post-fix credited <= paid rather than an exact
// number; this row pins the exact value.
.name = "IOU credit: overcredit fix across a scale boundary",
.assetsTotal = Number{9'999'999'999'999'999LL, -15},
@@ -463,6 +490,100 @@ private:
runCases(xrp, xrpCases);
}
// -------------------------------------------------------------------
// FixedPrecision vaults, credits: clampToAssetsTotalScale takes the
// roundToPosteriorVaultScale branch instead of the Legacy/CashBasis
// scale()-of-the-sum branch; it is FixedPrecision's only credit path
// (VaultDeposit). All rows below use a Scale-6 vault (baseScale -6)
// with assetsTotal already sitting on that grid, so TowardsZero-
// truncating delta itself to scale -6 is the whole story: no case
// here forces liveScale to coarsen past baseScale (that scenario needs
// a non-unit share price and is exercised by
// VaultFixedPrecision_test.cpp's testPartialTowardZeroRounding / dust
// tests through real transactions instead).
// -------------------------------------------------------------------
void
testFixedPrecisionCredit(Asset const& iou)
{
std::uint8_t const fixedScale = 6;
Number const onGrid{3'234'567, -6}; // 3.234567, exact at scale -6.
std::array<Case, 3> const cases{
Case{
// delta already exact at the base grid: passes through
// unchanged, same as a Legacy on-grid credit.
.name = "FixedPrecision credit: exact on the base grid",
.assetsTotal = onGrid,
.delta = Number{2, -6},
.expected = Number{2, -6},
},
Case{
// delta = +2.3 base units, truncates to 2 base units.
.name = "FixedPrecision credit: truncated toward zero on the base grid",
.assetsTotal = onGrid,
.delta = Number{23, -7},
.expected = Number{2, -6},
},
Case{
// delta = +0.4 base units: sub-ULP at the fixed grid, so
// TowardsZero truncates it entirely to zero.
.name = "FixedPrecision credit: sub-ULP dust rejected at the base grid",
.assetsTotal = onGrid,
.delta = Number{4, -7},
.expected = std::nullopt,
},
};
runCases(iou, cases, fixedScale);
}
// -------------------------------------------------------------------
// FixedPrecision vaults, debits: outflows (VaultWithdraw, VaultClawback)
// never reach clampToAssetsTotalScale's FixedPrecision branch, which
// only accepts non-negative deltas; they go through the public
// clampVaultOutflow dispatcher instead, which rounds at
// AssetsAvailable's own posterior scale (clampToAvailableScale). These
// rows pin the same truncation behavior through that entry point.
// -------------------------------------------------------------------
void
testFixedPrecisionDebit(Asset const& iou)
{
std::uint8_t const fixedScale = 6;
Number const onGrid{3'234'567, -6}; // 3.234567, exact at scale -6.
std::array<Case, 3> const cases{
Case{
// delta already exact at the base grid: passes through
// unchanged, same as a Legacy on-grid debit.
.name = "FixedPrecision debit: exact on the base grid",
.assetsTotal = onGrid,
.delta = Number{-1, -6},
.expected = Number{1, -6},
},
Case{
// delta = -1.7 base units. TowardsZero truncates the
// magnitude to 1 base unit -- this is the "books the
// truncated amount" behavior VaultFixedPrecision_test's
// testPartialTowardZeroRounding exercises end-to-end via
// VaultWithdraw; this row pins it at the helper level.
.name = "FixedPrecision debit: truncated toward zero on the base grid",
.assetsTotal = onGrid,
.delta = Number{-17, -7},
.expected = Number{1, -6},
},
Case{
// delta = -0.3 base units: sub-ULP at the fixed grid, so
// TowardsZero truncates it entirely to zero.
.name = "FixedPrecision debit: sub-ULP dust rejected at the base grid",
.assetsTotal = onGrid,
.delta = Number{-3, -7},
.expected = std::nullopt,
},
};
runCases(iou, cases, fixedScale, /* viaClampVaultOutflow */ true);
}
public:
void
run() override
@@ -477,6 +598,8 @@ public:
testIouDebits(iou);
testIouCredits(iou);
testIntegralAssets(mpt, xrp);
testFixedPrecisionCredit(iou);
testFixedPrecisionDebit(iou);
}
};

View File

@@ -0,0 +1,598 @@
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <protocol/VaultTestHelpers.h>
#include <cstdint>
#include <memory>
#include <string_view>
#include <vector>
namespace xrpl {
namespace {
beast::Journal const kNullJournal{beast::Journal::getNullSink()};
// Snapshots every field adjustVaultBalances can touch, for atomicity checks.
struct BalanceSnapshot
{
Number available;
Number assetsDeployed;
Number loss;
Number yield;
Number total;
static BalanceSnapshot
of(SLE::ConstRef vault)
{
return {
.available = vault->at(sfAssetsAvailable),
.assetsDeployed = vault->at(sfAssetsDeployed),
.loss = vault->at(sfLossUnrealized),
.yield = vault->at(sfYieldUnrealized),
.total = vault->at(sfAssetsTotal),
};
}
bool
operator==(BalanceSnapshot const&) const = default;
};
class VaultBalance : public ::testing::Test
{
protected:
test::Account const issuer_{"issuer"};
Issue const iou_{toCurrency("USD"), issuer_.id()};
// FixedPrecision IOU vault; stored is the (derived, hence possibly
// stale) sfAssetsTotal cache.
[[nodiscard]] std::shared_ptr<SLE>
iouVault(
Number const& assetsDeployed,
Number const& available,
Number const& stored = Number{0},
std::uint8_t scale = 6) const
{
return makeVault(
iou_, stored, VaultVersion::FixedPrecision, scale, assetsDeployed, available);
}
// AssetsDeployed 500, available 100.
[[nodiscard]] std::shared_ptr<SLE>
standardVault() const
{
return iouVault(Number{500}, Number{100});
}
[[nodiscard]] STAmount
iouAmount(Number const& n) const
{
return STAmount{iou_, n};
}
// Zero cash change: syncs the cached sfAssetsTotal.
static void
syncTotal(std::shared_ptr<SLE> const& vault)
{
ASSERT_EQ(adjustVaultBalances(vault, {}, kNullJournal), tesSUCCESS);
}
// Above coarsening the cached sfAssetsTotal (Downward, 16 digits) is a
// floor of the exact total: never above it, and within one unit of its
// own live exponent.
void
expectCacheFloorsExactTotal(std::shared_ptr<SLE> const& vault) const
{
Number const exact = getAssetsTotal(vault);
Number const cached = vault->at(sfAssetsTotal);
EXPECT_LE(cached, exact);
int const liveExponent = scale(cached, iou_);
EXPECT_LE(exact - cached, (Number{1, liveExponent}));
}
// Adjusts asset's integral vault by +25 and by an overdraft.
static void
checkIntegralControl(Asset const& asset)
{
auto const vault =
makeVault(asset, Number{0}, VaultVersion::FixedPrecision, 0, Number{500}, Number{100});
ASSERT_EQ(
adjustVaultBalances(vault, {.cash = STAmount{asset, Number{25}}}, kNullJournal),
tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsAvailable), Number{125});
EXPECT_EQ(vault->at(sfAssetsTotal), getAssetsTotal(vault));
EXPECT_EQ(
adjustVaultBalances(vault, {.cash = STAmount{asset, Number{-1'000}}}, kNullJournal),
tefBAD_LEDGER);
}
};
// ---------------------------------------------------------------------------
// getAssetsTotal
// ---------------------------------------------------------------------------
TEST_F(VaultBalance, get_assets_total_stored_for_non_fixed_precision)
{
struct Row
{
std::string_view name;
VaultVersion version = VaultVersion::Legacy;
std::int64_t stored = 0;
std::int64_t available = 0;
};
std::vector<Row> const rows{
{.name = "Legacy", .version = VaultVersion::Legacy, .stored = 1'234'567, .available = 999},
{
.name = "CashBasis",
.version = VaultVersion::CashBasis,
.stored = 9'876'543,
.available = 100,
},
};
for (auto const& row : rows)
{
SCOPED_TRACE(row.name);
auto const vault =
makeVault(iou_, Number{row.stored}, row.version, 6, Number{0}, Number{row.available});
EXPECT_EQ(getAssetsTotal(vault), Number{row.stored});
EXPECT_FALSE(vault->isFieldPresent(sfAssetsDeployed));
}
}
TEST_F(VaultBalance, get_assets_total_fixed_precision_derived_sum)
{
struct Row
{
std::string_view name;
Number assetsDeployed;
Number available;
// The synced cache equals the exact total (not merely a floor of it).
bool cacheExact = false;
};
std::vector<Row> const rows{
{
.name = "ExactSum",
.assetsDeployed = Number{250'000},
.available = Number{1'000'000},
.cacheExact = true,
},
// available is already 16 significant digits; assetsDeployed pushes the exact
// sum to 17, past what a 16-digit STAmount represents exactly.
{
.name = "ExactPastSixteenDigits",
.assetsDeployed = Number{8, -6},
.available = Number{9'999'999'999'999'999, -6},
},
};
for (auto const& row : rows)
{
SCOPED_TRACE(row.name);
auto const vault = iouVault(row.assetsDeployed, row.available);
Number const exact = row.available + row.assetsDeployed;
// Exact sum: no rounding, no conversion to a 16-digit STAmount.
EXPECT_EQ(getAssetsTotal(vault), exact);
EXPECT_GE(getAssetsTotal(vault), vault->at(sfAssetsAvailable));
syncTotal(vault);
if (row.cacheExact)
{
EXPECT_EQ(vault->at(sfAssetsTotal), getAssetsTotal(vault));
}
expectCacheFloorsExactTotal(vault);
}
}
TEST_F(VaultBalance, get_assets_total_fixed_precision_integral_exact)
{
auto const vault = makeVault(
xrpIssue(), Number{0}, VaultVersion::FixedPrecision, 0, Number{250}, Number{1'000});
EXPECT_EQ(getAssetsTotal(vault), Number{1'250});
}
TEST_F(VaultBalance, get_assets_total_ignores_stale_cache)
{
Number const available{4'000'000};
Number const assetsDeployed{1'000'000};
auto const vault = iouVault(assetsDeployed, available, Number{99});
EXPECT_EQ(getAssetsTotal(vault), available + assetsDeployed);
EXPECT_NE(vault->at(sfAssetsTotal), getAssetsTotal(vault));
}
// The exact total, and the synced cache once Downward-rounded, must never
// fall below AssetsAvailable even when assetsDeployed coarsens the scale.
TEST_F(VaultBalance, get_assets_total_never_below_available)
{
Number const available{9'999'999'999'999'999, -6};
Number const assetsDeployed{5'000'000'000, -6};
auto const vault = iouVault(assetsDeployed, available);
EXPECT_GT(getVaultScale(vault), getVaultBaseScale(vault));
EXPECT_GE(getAssetsTotal(vault), available);
syncTotal(vault);
EXPECT_GE(Number(vault->at(sfAssetsTotal)), available);
}
// available and assetsDeployed are each at most 16 digits, so associateAsset leaves them
// alone. Their sum, 99999999999999995 at exponent -4, has a 17th digit (5)
// that would round up under ToNearest and carry through the run of nines,
// bumping the exponent. Downward truncation drops it without a bump.
TEST_F(VaultBalance, live_scale_downward_keeps_lower_exponent_at_carry_boundary)
{
auto vault = iouVault(Number{35, -4}, Number{9'999'999'999'999'996LL, -3});
int const downwardExponent = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return scale(getAssetsTotal(vault), iou_);
}();
int const toNearestExponent = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return scale(getAssetsTotal(vault), iou_);
}();
EXPECT_LT(downwardExponent, toNearestExponent);
EXPECT_EQ(getVaultScale(vault), downwardExponent);
syncTotal(vault);
EXPECT_EQ(scale(vault->at(sfAssetsTotal), iou_), downwardExponent);
EXPECT_EQ(getVaultScale(vault), scale(vault->at(sfAssetsTotal), iou_));
}
TEST_F(VaultBalance, scale_and_open_include_assets_deployed)
{
// Available alone stays at base scale; Available + AssetsDeployed coarsens.
Number const available{1'000'000'000};
Number const assetsDeployed{9'000'000'000};
auto const vault = iouVault(assetsDeployed, available, available);
EXPECT_EQ(getAssetsTotal(vault), available + assetsDeployed);
EXPECT_GT(getVaultScale(vault), getVaultBaseScale(vault));
// Open capacity uses the derived total, so assetsDeployed counts against the
// ceiling: with assetsDeployed filling most of Open, the inflow is rejected.
auto const nearOpen = iouVault(Number{1, 9}, Number{8, 9});
EXPECT_EQ(checkOptionalVaultInflow(nearOpen, iouAmount(Number{1})), tecLIMIT_EXCEEDED);
}
// ---------------------------------------------------------------------------
// adjustVaultBalances
// ---------------------------------------------------------------------------
TEST_F(VaultBalance, fixed_precision_adjust_balances)
{
Number const assetsDeployed{300};
auto const vault = iouVault(assetsDeployed, Number{100}, Number{99});
ASSERT_EQ(
adjustVaultBalances(vault, {.cash = iouAmount(Number{50})}, kNullJournal), tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsAvailable), Number{150});
EXPECT_EQ(vault->at(sfAssetsDeployed), assetsDeployed);
EXPECT_EQ(vault->at(sfAssetsTotal), getAssetsTotal(vault));
EXPECT_EQ(getAssetsTotal(vault), Number{450});
ASSERT_EQ(
adjustVaultBalances(vault, {.cash = iouAmount(Number{-20})}, kNullJournal), tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsAvailable), Number{130});
EXPECT_EQ(vault->at(sfAssetsDeployed), assetsDeployed);
EXPECT_EQ(vault->at(sfAssetsTotal), getAssetsTotal(vault));
EXPECT_EQ(getAssetsTotal(vault), Number{430});
// Clear both rails together, the way a final VaultWithdraw does.
ASSERT_EQ(
adjustVaultBalances(
vault,
{.cash = iouAmount(Number{-130}), .deployed = -Number(assetsDeployed)},
kNullJournal),
tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsAvailable), Number{0});
EXPECT_EQ(vault->at(sfAssetsDeployed), Number{0});
EXPECT_EQ(vault->at(sfAssetsTotal), Number{0});
EXPECT_EQ(getAssetsTotal(vault), Number{0});
}
TEST_F(VaultBalance, fixed_precision_add_above_coarsening_syncs)
{
Number const assetsDeployed{5, -6};
auto const vault = iouVault(assetsDeployed, Number{9'999'999'999'999'990, -6});
ASSERT_EQ(
adjustVaultBalances(vault, {.cash = iouAmount(Number{10, -6})}, kNullJournal), tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsDeployed), assetsDeployed);
expectCacheFloorsExactTotal(vault);
}
// For each (balance, delta), AssetsAvailable after adjustVaultBalances must
// equal the plain STAmount sum, the arithmetic the ledger uses for the real
// trust-line/MPT transfer.
TEST_F(VaultBalance, trust_line_arithmetic_mirror)
{
struct Row
{
std::string_view name;
Number balance;
Number delta;
};
std::vector<Row> const rows{
{.name = "Plain", .balance = Number{1'000'000}, .delta = Number{500'000}},
{
.name = "CreditCrossesPowerOfTenUp",
.balance = Number{9'999'999'999'999'999, -9},
.delta = Number{2, -9},
},
{
.name = "DebitCrossesPowerOfTenDown",
.balance = Number{1'000'000'000'000'000, -9},
.delta = Number{-1, -9},
},
{.name = "DebitPartial", .balance = Number{5'000'000}, .delta = Number{-3'000'000}},
{.name = "DebitToZero", .balance = Number{2'000'000}, .delta = Number{-2'000'000}},
{
.name = "CreditFinerThanBalanceGrid",
.balance = Number{1'873'013'129'122'272LL, -9},
.delta = Number{7, -10},
},
};
for (auto const& row : rows)
{
SCOPED_TRACE(row.name);
STAmount const balance{iou_, row.balance};
STAmount const delta{iou_, row.delta};
STAmount const expected{iou_, Number(balance) + Number(delta)};
auto const vault = iouVault(Number{0}, row.balance, Number{0}, 9);
ASSERT_EQ(adjustVaultBalances(vault, {.cash = delta}, kNullJournal), tesSUCCESS);
EXPECT_EQ(Number(vault->at(sfAssetsAvailable)), Number(expected));
}
}
// Each row starts from assetsDeployed 500, available 100.
TEST_F(VaultBalance, fields_move)
{
struct Row
{
std::string_view name;
std::int64_t cash = 0;
std::int64_t deployed = 0;
std::int64_t yield = 0;
std::int64_t loss = 0;
std::int64_t expectedAvailable = 100;
std::int64_t expectedAssetsDeployed = 500;
std::int64_t expectedYield = 0;
std::int64_t expectedLoss = 0;
};
std::vector<Row> const rows{
{.name = "CashAlone", .cash = 25, .expectedAvailable = 125},
{.name = "AssetsDeployedAlone", .deployed = -200, .expectedAssetsDeployed = 300},
{.name = "YieldAlone", .yield = 40, .expectedYield = 40},
{.name = "LossAlone", .loss = 150, .expectedLoss = 150},
{
.name = "AllTogether",
.cash = 40,
.deployed = -100,
.yield = 10,
.loss = 50,
.expectedAvailable = 140,
.expectedAssetsDeployed = 400,
.expectedYield = 10,
.expectedLoss = 50,
},
};
for (auto const& row : rows)
{
SCOPED_TRACE(row.name);
auto const vault = standardVault();
ASSERT_EQ(
adjustVaultBalances(
vault,
{.cash = iouAmount(Number{row.cash}),
.deployed = Number{row.deployed},
.yield = Number{row.yield},
.loss = Number{row.loss}},
kNullJournal),
tesSUCCESS);
EXPECT_EQ(vault->at(sfAssetsAvailable), Number{row.expectedAvailable});
EXPECT_EQ(vault->at(sfAssetsDeployed), Number{row.expectedAssetsDeployed});
EXPECT_EQ(vault->at(sfYieldUnrealized), Number{row.expectedYield});
EXPECT_EQ(vault->at(sfLossUnrealized), Number{row.expectedLoss});
EXPECT_EQ(vault->at(sfAssetsTotal), getAssetsTotal(vault));
}
}
// A failing field rejects the whole change as a unit, including any valid
// cash leg. Each row starts from assetsDeployed 500, available 100.
TEST_F(VaultBalance, invalid_change_fails_atomically)
{
struct Row
{
std::string_view name;
std::int64_t cash = 0;
std::int64_t deployed = 0;
std::int64_t loss = 0;
TER expected;
};
std::vector<Row> const rows{
{.name = "AssetsAvailableNegative", .cash = -200, .expected = tefBAD_LEDGER},
{.name = "AssetsDeployedNegative", .deployed = -600, .expected = tefBAD_LEDGER},
// The +50 credit is valid on its own; the deployed change is not.
{
.name = "AssetsDeployedNegativeWithValidCash",
.cash = 50,
.deployed = -600,
.expected = tefBAD_LEDGER,
},
// The -50 debit is valid on its own (available stays at 50); the
// loss exceeds AssetsDeployed.
{
.name = "LossExceedsAssetsDeployedWithValidCash",
.cash = -50,
.loss = 600,
.expected = tecLIMIT_EXCEEDED,
},
{.name = "LossUnrealizedNegative", .loss = -1, .expected = tefBAD_LEDGER},
{.name = "LossExceedsAssetsDeployed", .loss = 600, .expected = tecLIMIT_EXCEEDED},
};
for (auto const& row : rows)
{
SCOPED_TRACE(row.name);
auto const vault = standardVault();
BalanceSnapshot const before = BalanceSnapshot::of(vault);
EXPECT_EQ(
adjustVaultBalances(
vault,
{.cash = iouAmount(Number{row.cash}),
.deployed = Number{row.deployed},
.loss = Number{row.loss}},
kNullJournal),
row.expected);
EXPECT_EQ(BalanceSnapshot::of(vault), before);
}
}
TEST_F(VaultBalance, loss_at_assets_deployed_boundary_allowed)
{
auto const vault = standardVault();
EXPECT_EQ(adjustVaultBalances(vault, {.loss = Number{500}}, kNullJournal), tesSUCCESS);
EXPECT_EQ(vault->at(sfLossUnrealized), Number{500});
}
TEST_F(VaultBalance, negative_yield_unrealized_rejected)
{
auto const vault = standardVault();
vault->at(sfYieldUnrealized) = Number{5};
auto const before = BalanceSnapshot::of(vault);
EXPECT_EQ(adjustVaultBalances(vault, {.yield = Number{-20}}, kNullJournal), tefBAD_LEDGER);
EXPECT_EQ(BalanceSnapshot::of(vault), before);
}
TEST_F(VaultBalance, assets_total_equals_floor16_of_available_plus_assets_deployed)
{
Number const assetsDeployed{7, -9};
auto vault = iouVault(assetsDeployed, Number{9'999'999'999'999'999, -9}, Number{0}, 9);
syncTotal(vault);
Number const expected = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
return Number(STAmount{iou_, Number(vault->at(sfAssetsAvailable)) + assetsDeployed});
}();
EXPECT_EQ(Number(vault->at(sfAssetsTotal)), expected);
}
// ---------------------------------------------------------------------------
// Integral assets
// ---------------------------------------------------------------------------
TEST_F(VaultBalance, xrp_control)
{
checkIntegralControl(xrpIssue());
}
TEST_F(VaultBalance, mpt_control)
{
checkIntegralControl(MPTIssue{makeMptID(1, issuer_.id())});
}
TEST_F(VaultBalance, credit_to_posterior_scale_ignores_ambient_rounding_mode)
{
// reference is on a fine grid (scale -10); raw needs a coarser atScale once
// reference + raw is floored, so flooredSum - reference needs more digits
// than fit in a 16-digit STAmount. creditToPosteriorScale must build that
// difference under its own roundingMode (Downward here), not whatever
// rounding mode happens to be ambient when it is called.
Number const reference{4'218'667'505'995'833LL, -10};
STAmount const raw{iou_, std::int64_t{4'004'426'170'440'612LL}, -8};
int const atScale = -7;
// The exact (pre-STAmount) Downward credit, computed independently, floors
// strictly below raw: part of raw is lost rounding reference + raw onto
// the coarser atScale grid.
Number const exactDownwardCredit = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::Downward);
Number const flooredSum =
roundToAsset(iou_, reference + Number(raw), atScale, Number::RoundingMode::Downward);
return Number(STAmount{iou_, flooredSum - reference});
}();
EXPECT_LT(exactDownwardCredit, Number(raw));
// Calling under an ambient ToNearest mode (the common case: no caller sets
// an explicit guard around this call) must still produce the Downward
// result, not a value rounded under the ambient mode.
NumberRoundModeGuard const ambient(Number::RoundingMode::ToNearest);
STAmount const credit = detail::creditToPosteriorScale(
iou_, reference, atScale, raw, Number::RoundingMode::Downward);
EXPECT_LT(Number(credit), Number(raw));
EXPECT_EQ(Number(credit), exactDownwardCredit);
}
// creditToPosteriorAvailableScale returns
// floor16(AssetsAvailable + raw) - AssetsAvailable, never finer than -Scale.
TEST_F(VaultBalance, credit_to_posterior_available_scale_integral_passes_through)
{
auto const vault = makeVault(xrpIssue(), Number{1'000}, VaultVersion::FixedPrecision);
STAmount const credit =
creditToPosteriorAvailableScale(vault, Number{250}, Number::RoundingMode::Downward);
EXPECT_EQ(credit, STAmount(xrpIssue(), 250));
}
TEST_F(VaultBalance, credit_to_posterior_available_scale_floors_to_base_grid)
{
// Scale 6: the base grid is 1e-6, and AssetsAvailable is small enough
// that the posterior grid is the base grid.
auto const vault = iouVault(Number{0}, Number{100});
STAmount const credit = creditToPosteriorAvailableScale(
vault, Number{12'345'678, -7}, Number::RoundingMode::Downward);
EXPECT_EQ(credit, iouAmount(Number{1'234'567, -6}));
}
TEST_F(VaultBalance, credit_to_posterior_available_scale_floors_the_sum_across_power_of_ten)
{
// 9999999999.999999 + 0.000011 crosses 10^10, where the 16-digit grid is
// 1e-5. Flooring the delta alone would give 0.00001; flooring the sum
// gives 10000000000.00001, so the credit is exactly 0.000011.
auto const vault = iouVault(Number{0}, Number{9'999'999'999'999'999, -6});
STAmount const credit =
creditToPosteriorAvailableScale(vault, Number{11, -6}, Number::RoundingMode::Downward);
EXPECT_EQ(credit, iouAmount(Number{11, -6}));
EXPECT_EQ(
STAmount(iou_, Number{9'999'999'999'999'999, -6} + Number(credit)),
iouAmount(Number{1'000'000'000'000'001, -5}));
}
TEST_F(VaultBalance, credit_to_posterior_available_scale_uses_the_exact_raw_amount)
{
// raw has 17 significant digits. Exact: 0.000009 + 12345678901.234561 =
// 12345678901.23457 on the 1e-5 grid, so the credit is
// 12345678901.234561, stored as 12345678901.23456. Rounding raw to 16
// digits first (12345678901.23456) would floor the sum to
// 12345678901.23456 and give a credit one unit lower, 12345678901.23455.
auto const vault = iouVault(Number{0}, Number{9, -6});
STAmount const credit = creditToPosteriorAvailableScale(
vault, Number{12'345'678'901'234'561, -6}, Number::RoundingMode::Downward);
EXPECT_EQ(credit, iouAmount(Number{1'234'567'890'123'456, -5}));
}
} // namespace
} // namespace xrpl

View File

@@ -0,0 +1,376 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/hash/uhash.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <gtest/gtest.h>
#include <helpers/Account.h>
#include <protocol/VaultTestHelpers.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <unordered_set>
namespace xrpl {
namespace {
TEST(VaultGrid, base_and_live_scale)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const legacy = makeVault(iou, Number{1'000'000}, VaultVersion::Legacy, 6);
EXPECT_EQ(getVaultScale(legacy), -9);
EXPECT_EQ(getVaultBaseScale(legacy), getVaultScale(legacy));
auto const empty = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 6);
EXPECT_EQ(getVaultScale(empty), -6);
EXPECT_EQ(getVaultBaseScale(empty), -6);
auto const small = makeVault(iou, Number{1'000'000}, VaultVersion::FixedPrecision, 6);
EXPECT_EQ(getVaultScale(small), -6);
EXPECT_EQ(getVaultBaseScale(small), -6);
auto const coarsened = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6);
EXPECT_GT(getVaultScale(coarsened), getVaultBaseScale(coarsened));
EXPECT_EQ(getVaultBaseScale(coarsened), -6);
}
TEST(VaultGrid, pre_v12_behavior_is_preserved)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
Number const assetsTotal{1'000'000};
STAmount const onGrid{iou, Number{2, -9}};
STAmount const dust{iou, Number{4, -10}};
STAmount const overOpen{iou, Number{10, 9}};
auto const downward = Number::RoundingMode::Downward;
auto const legacy = makeVault(iou, assetsTotal, VaultVersion::Legacy);
int const expectedLive = getVaultScale(legacy);
int const expectedPosterior = detail::getPosteriorVaultScale(legacy, onGrid);
STAmount const expectedPosteriorRound = roundToPosteriorVaultScale(legacy, dust, downward);
for (auto const version : {
std::optional<VaultVersion>{},
std::optional{VaultVersion::Legacy},
std::optional{VaultVersion::CashBasis},
})
{
auto const vault = makeVault(iou, assetsTotal, version);
EXPECT_EQ(getVaultScale(vault), expectedLive);
EXPECT_EQ(getVaultBaseScale(vault), expectedLive);
EXPECT_EQ(detail::getPosteriorVaultScale(vault, onGrid), expectedPosterior);
EXPECT_EQ(roundToPosteriorVaultScale(vault, dust, downward), expectedPosteriorRound);
EXPECT_EQ(checkOptionalVaultInflow(vault, overOpen), tesSUCCESS);
}
}
TEST(VaultGrid, pre_v12_credit_clamp_floors_posterior_total)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
Number const assetsTotal{9'999'999'999'999'999LL, -15};
STAmount const delta{iou, Number{5}};
STAmount const expected{iou, Number{4'999'999'999'999'991LL, -15}};
for (auto const version : {
std::optional<VaultVersion>{},
std::optional{VaultVersion::Legacy},
std::optional{VaultVersion::CashBasis},
})
{
auto const result = clampToAssetsTotalScale(makeVault(iou, assetsTotal, version), delta);
ASSERT_TRUE(result.has_value());
EXPECT_EQ(*result, expected);
}
}
TEST(VaultGrid, integral_scale_is_zero)
{
auto const vault = makeVault(xrpIssue(), Number{1'000}, VaultVersion::FixedPrecision, 0);
STAmount const delta{xrpIssue(), 7};
EXPECT_EQ(getVaultScale(vault), 0);
EXPECT_EQ(getVaultBaseScale(vault), 0);
EXPECT_EQ(detail::getPosteriorVaultScale(vault, delta), 0);
EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta);
}
TEST(VaultGrid, posterior_scale_rounds_delta)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault =
makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6);
STAmount const delta{iou, Number{21, -6}};
EXPECT_EQ(getVaultScale(vault), -6);
EXPECT_EQ(detail::getPosteriorVaultScale(vault, delta), -5);
EXPECT_EQ(
roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero),
STAmount(iou, Number{20, -6}));
}
TEST(VaultGrid, posterior_scale_rejects_dust_at_call_site)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(iou, Number{10'000'000'000}, VaultVersion::FixedPrecision, 6);
STAmount const dust{iou, Number{1, -6}};
EXPECT_EQ(
roundToPosteriorVaultScale(vault, dust, Number::RoundingMode::TowardsZero), beast::kZero);
}
TEST(VaultGrid, posterior_outflow_can_refine_scale)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault =
makeVault(iou, Number{1'000'000'000'000'001, -5}, VaultVersion::FixedPrecision, 6);
STAmount const delta{iou, -Number{11, -6}};
EXPECT_EQ(getVaultScale(vault), -5);
EXPECT_EQ(detail::getPosteriorVaultScale(vault, delta), -6);
EXPECT_EQ(roundToPosteriorVaultScale(vault, delta, Number::RoundingMode::TowardsZero), delta);
}
TEST(VaultGrid, optional_inflow_capacity_boundaries)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const fixedIou = makeVault(iou, Number{9, 5}, VaultVersion::FixedPrecision, 10);
STAmount const iouDust{iou, Number{1, -10}};
EXPECT_EQ(getVaultOpenLimit(fixedIou), (Number{9, 5}));
EXPECT_EQ(checkOptionalVaultInflow(fixedIou, STAmount{iou}), tesSUCCESS);
EXPECT_EQ(checkOptionalVaultInflow(fixedIou, iouDust), tecLIMIT_EXCEEDED);
auto const fixedXrp = makeVault(xrpIssue(), Number{9, 15}, VaultVersion::FixedPrecision, 0);
STAmount const xrpUnit{xrpIssue(), 1};
EXPECT_EQ(getVaultOpenLimit(fixedXrp), (Number{9, 15}));
EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, STAmount{xrpIssue()}), tesSUCCESS);
EXPECT_EQ(checkOptionalVaultInflow(fixedXrp, xrpUnit), tecLIMIT_EXCEEDED);
auto const legacy = makeVault(iou, Number{10, 5}, VaultVersion::Legacy, 10);
EXPECT_EQ(checkOptionalVaultInflow(legacy, iouDust), tesSUCCESS);
auto const coarsening =
makeVault(iou, Number{9'999'999'999'999'999, -6}, VaultVersion::FixedPrecision, 6);
STAmount const coarseningDelta{iou, Number{21, -6}};
EXPECT_EQ(checkOptionalVaultInflow(coarsening, coarseningDelta), tecLIMIT_EXCEEDED);
}
TEST(VaultGrid, optional_inflow_includes_yield_unrealized)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(iou, Number{8'999'999'999}, VaultVersion::FixedPrecision, 6);
STAmount const amount{iou, Number{1}};
EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tesSUCCESS);
vault->at(sfYieldUnrealized) = Number{1};
associateAsset(*vault, iou);
EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tecLIMIT_EXCEEDED);
}
// AssetsAvailable + raw needs 20 digits. Under the ToNearest ambient mode the
// sum would round up to the next 10^-6 before the Downward floor, crediting
// 0.000001 for a raw 0.0000009996.
TEST(VaultGrid, credit_floors_sum_in_requested_mode)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(
iou,
Number{0},
VaultVersion::FixedPrecision,
6,
Number{0},
Number{1'234'567'890'123'456LL, -6});
Number const raw{9'996, -10};
NumberRoundModeGuard const ambient(Number::RoundingMode::ToNearest);
STAmount const credit =
creditToPosteriorAvailableScale(vault, raw, Number::RoundingMode::Downward);
EXPECT_EQ(credit, STAmount(iou, 0));
EXPECT_LE(Number(credit), raw);
}
TEST(VaultGrid, is_on_vault_base_grid)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
int const baseScale = vaultBaseScale(iou, 6);
struct Row
{
Number value;
bool expected{};
};
Row const rows[] = {
{.value = Number{0}, .expected = true},
{.value = Number{1}, .expected = true},
{.value = Number{1, -6}, .expected = true},
{.value = Number{1, 20}, .expected = true},
{.value = Number{1'234'567'890'123'456LL, -6}, .expected = true},
// A digit below baseScale.
{.value = Number{1, -7}, .expected = false},
{.value = Number{15, -7}, .expected = false},
// A 17th significant digit, on the base grid but not an STAmount.
{.value = Number{12'345'678'901'234'567LL, 0}, .expected = false},
};
for (auto const& row : rows)
EXPECT_EQ(isOnVaultBaseGrid(iou, row.value, baseScale), row.expected) << row.value;
EXPECT_EQ(vaultBaseScale(xrpIssue(), 6), 0);
EXPECT_TRUE(isOnVaultBaseGrid(xrpIssue(), Number{7}, 0));
// Out of range for the asset: rejected instead of throwing.
EXPECT_FALSE(isOnVaultBaseGrid(
xrpIssue(), Number{static_cast<std::int64_t>(STAmount::kMaxNativeN)} + 1, 0));
EXPECT_FALSE(isOnVaultBaseGrid(iou, Number{1, 100}, baseScale));
}
TEST(VaultGrid, clamp_vault_outflow)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
Asset const mpt{MPTIssue{makeMptID(1, issuer.id())}};
struct Row
{
char const* name = nullptr;
Asset asset;
Number available;
Number delta;
std::optional<Number> expected; // nullopt: tecPRECISION_LOSS
};
Row const rows[] = {
{
.name = "XRP outflow is returned unrounded",
.asset = xrpIssue(),
.available = Number{1'000},
.delta = Number{-7},
.expected = Number{7},
},
{
.name = "MPT outflow is returned unrounded",
.asset = mpt,
.available = Number{1'000},
.delta = Number{-7},
.expected = Number{7},
},
{
.name = "IOU outflow rounds towards zero at the posterior scale",
.asset = iou,
.available = Number{1'000'000},
.delta = Number{-15, -7},
.expected = Number{1, -6},
},
{
.name = "IOU outflow below one unit at the posterior scale is rejected",
.asset = iou,
.available = Number{1'000'000},
.delta = Number{-4, -7},
.expected = std::nullopt,
},
};
for (auto const& row : rows)
{
auto const vault = makeVault(
row.asset,
row.available,
VaultVersion::FixedPrecision,
row.asset.integral() ? 0 : 6,
Number{0},
row.available);
auto const result = clampVaultOutflow(vault, STAmount{row.asset, row.delta});
if (row.expected)
{
ASSERT_TRUE(result.has_value()) << row.name;
EXPECT_EQ(*result, (STAmount{row.asset, *row.expected})) << row.name;
}
else
{
ASSERT_FALSE(result.has_value()) << row.name;
EXPECT_EQ(result.error(), tecPRECISION_LOSS) << row.name;
}
}
}
TEST(VaultGrid, get_vault_default_version)
{
struct Row
{
char const* name = nullptr;
std::unordered_set<UInt256, beast::Uhash<>> enabled;
VaultVersion expected;
};
Row const rows[] = {
{.name = "no lending amendments: Legacy", .enabled = {}, .expected = VaultVersion::Legacy},
{
.name = "LendingProtocolV1_1: CashBasis",
.enabled = {featureLendingProtocolV1_1},
.expected = VaultVersion::CashBasis,
},
{
.name = "V1_1 and V1_2 without the cleanups: CashBasis",
.enabled = {featureLendingProtocolV1_1, featureLendingProtocolV1_2},
.expected = VaultVersion::CashBasis,
},
{
.name = "V1_1, V1_2 and fixCleanup3_2_0 without fixCleanup3_4_0: CashBasis",
.enabled = {featureLendingProtocolV1_1, featureLendingProtocolV1_2, fixCleanup3_2_0},
.expected = VaultVersion::CashBasis,
},
{
.name = "V1_1, V1_2 and fixCleanup3_4_0 without fixCleanup3_2_0: CashBasis",
.enabled = {featureLendingProtocolV1_1, featureLendingProtocolV1_2, fixCleanup3_4_0},
.expected = VaultVersion::CashBasis,
},
{
.name = "V1_2 and both cleanups: FixedPrecision",
.enabled = {featureLendingProtocolV1_2, fixCleanup3_2_0, fixCleanup3_4_0},
.expected = VaultVersion::FixedPrecision,
},
{
.name = "V1_1, V1_2 and both cleanups: FixedPrecision",
.enabled =
{
featureLendingProtocolV1_1,
featureLendingProtocolV1_2,
fixCleanup3_2_0,
fixCleanup3_4_0,
},
.expected = VaultVersion::FixedPrecision,
},
};
for (auto const& row : rows)
{
// Rules keeps a reference to the preset set; row.enabled outlives it.
Rules const rules{row.enabled};
EXPECT_EQ(getVaultDefaultVersion(rules), row.expected) << row.name;
}
}
} // namespace
} // namespace xrpl

View File

@@ -0,0 +1,65 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
// Needed for standalone compilation of sfScale/sfLEVersion assignment below;
// full-TU builds already see it transitively, so misc-include-cleaner flags
// it as unused there. Keep it so this header still compiles on its own.
#include <xrpl/protocol/STInteger.h> // NOLINT(misc-include-cleaner)
#include <xrpl/protocol/STIssue.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTakesAsset.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <utility>
namespace xrpl {
// Shared by VaultGridTests, VaultBalanceTests and LoanBrokerCoverTests. For
// FixedPrecision, getAssetsTotal derives from AssetsAvailable plus
// AssetsDeployed rather than reading the stored AssetsTotal cache, so a
// FixedPrecision vault must have AssetsAvailable (and, if requested, a
// non-zero AssetsDeployed) seeded separately from assetsTotal. By default
// AssetsAvailable is assetsTotal - assetsDeployed, so the derived total
// equals assetsTotal; pass assetsAvailable explicitly to decouple it (e.g.
// to leave assetsTotal as a deliberately stale cache). Legacy/CashBasis
// vaults have no AssetsDeployed field and normally leave AssetsAvailable
// unset, but some cash-flow tests need it seeded too, so assetsAvailable is
// honored for those versions as well when given.
inline std::shared_ptr<SLE>
makeVault(
Asset const& asset,
Number const& assetsTotal,
std::optional<VaultVersion> version,
std::uint8_t scaleValue = kVaultDefaultIouScale,
Number const& assetsDeployed = Number{0},
std::optional<Number> assetsAvailable = std::nullopt)
{
auto vault = std::make_shared<SLE>(keylet::vault(uint256(1)));
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfAssetsTotal) = assetsTotal;
if (!asset.integral())
vault->at(sfScale) = scaleValue;
if (version)
vault->at(sfLEVersion) = std::to_underlying(*version);
if (version == VaultVersion::FixedPrecision)
{
vault->at(sfAssetsAvailable) = assetsAvailable.value_or(assetsTotal - assetsDeployed);
vault->at(sfAssetsDeployed) = assetsDeployed;
}
else if (assetsAvailable)
{
vault->at(sfAssetsAvailable) = *assetsAvailable;
}
associateAsset(*vault, asset);
return vault;
}
} // namespace xrpl

View File

@@ -32,6 +32,8 @@ TEST(VaultTests, BuilderSettersRoundTrip)
auto const assetsAvailableValue = canonical_NUMBER();
auto const assetsMaximumValue = canonical_NUMBER();
auto const lossUnrealizedValue = canonical_NUMBER();
auto const yieldUnrealizedValue = canonical_NUMBER();
auto const assetsDeployedValue = canonical_NUMBER();
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
@@ -57,6 +59,8 @@ TEST(VaultTests, BuilderSettersRoundTrip)
builder.setAssetsAvailable(assetsAvailableValue);
builder.setAssetsMaximum(assetsMaximumValue);
builder.setLossUnrealized(lossUnrealizedValue);
builder.setYieldUnrealized(yieldUnrealizedValue);
builder.setAssetsDeployed(assetsDeployedValue);
builder.setScale(scaleValue);
builder.setLEVersion(lEVersionValue);
builder.setVaultKind(vaultKindValue);
@@ -166,6 +170,22 @@ TEST(VaultTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasLossUnrealized());
}
{
auto const& expected = yieldUnrealizedValue;
auto const actualOpt = entry.getYieldUnrealized();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfYieldUnrealized");
EXPECT_TRUE(entry.hasYieldUnrealized());
}
{
auto const& expected = assetsDeployedValue;
auto const actualOpt = entry.getAssetsDeployed();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfAssetsDeployed");
EXPECT_TRUE(entry.hasAssetsDeployed());
}
{
auto const& expected = scaleValue;
auto const actualOpt = entry.getScale();
@@ -231,6 +251,8 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
auto const assetsAvailableValue = canonical_NUMBER();
auto const assetsMaximumValue = canonical_NUMBER();
auto const lossUnrealizedValue = canonical_NUMBER();
auto const yieldUnrealizedValue = canonical_NUMBER();
auto const assetsDeployedValue = canonical_NUMBER();
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
@@ -253,6 +275,8 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
sle->at(sfAssetsAvailable) = assetsAvailableValue;
sle->at(sfAssetsMaximum) = assetsMaximumValue;
sle->at(sfLossUnrealized) = lossUnrealizedValue;
sle->at(sfYieldUnrealized) = yieldUnrealizedValue;
sle->at(sfAssetsDeployed) = assetsDeployedValue;
sle->at(sfShareMPTID) = shareMPTIDValue;
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
sle->at(sfScale) = scaleValue;
@@ -425,6 +449,32 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfLossUnrealized");
}
{
auto const& expected = yieldUnrealizedValue;
auto const fromSleOpt = entryFromSle.getYieldUnrealized();
auto const fromBuilderOpt = entryFromBuilder.getYieldUnrealized();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfYieldUnrealized");
expectEqualField(expected, *fromBuilderOpt, "sfYieldUnrealized");
}
{
auto const& expected = assetsDeployedValue;
auto const fromSleOpt = entryFromSle.getAssetsDeployed();
auto const fromBuilderOpt = entryFromBuilder.getAssetsDeployed();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfAssetsDeployed");
expectEqualField(expected, *fromBuilderOpt, "sfAssetsDeployed");
}
{
auto const& expected = scaleValue;
@@ -570,6 +620,10 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getAssetsMaximum().has_value());
EXPECT_FALSE(entry.hasLossUnrealized());
EXPECT_FALSE(entry.getLossUnrealized().has_value());
EXPECT_FALSE(entry.hasYieldUnrealized());
EXPECT_FALSE(entry.getYieldUnrealized().has_value());
EXPECT_FALSE(entry.hasAssetsDeployed());
EXPECT_FALSE(entry.getAssetsDeployed().has_value());
EXPECT_FALSE(entry.hasScale());
EXPECT_FALSE(entry.getScale().has_value());
EXPECT_FALSE(entry.hasLEVersion());