Compare commits

...

40 Commits

Author SHA1 Message Date
Vito
2dc2d4f8f7 Merge remote-tracking branch 'origin/tapanito/lending-fix-amendment' into tapanito/vault-share-pricing 2026-03-31 12:28:15 +02:00
Vito
68e4fbdf2b Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-31 10:00:59 +02:00
Vito
bb0a09ae21 Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-26 17:16:49 +01:00
Vito
d94232007f fix: updates autogen files 2026-03-24 14:34:54 +01:00
Vito
d9487a02bb Merge remote-tracking branch 'origin/tapanito/lending-fix-amendment' into tapanito/vault-share-pricing 2026-03-24 14:14:11 +01:00
Vito
df8bfbe5af fix: errors introduced post-merge 2026-03-24 12:37:06 +01:00
Vito
347d1a19ef Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-24 12:35:50 +01:00
Vito
a5ce36ea42 refactor: docstrings, state validation, v1 routing tests 2026-03-24 12:33:02 +01:00
Vito
8f7a813ac1 refactor: add borrowFromVault helper with state validation 2026-03-24 11:53:04 +01:00
Vito
c0fc689113 refactor: add vault interest unrealized invariants 2026-03-24 11:52:28 +01:00
Vito Tumas
d65fab27a1 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-21 14:39:10 +01:00
Vito
a8914da275 refactor: document rounding behaviour, consolidate deposit NAV check 2026-03-19 17:48:08 +01:00
Vito
59148bb7a5 refactor: vault share pricing safety and Expected API 2026-03-19 12:35:33 +01:00
Vito
bf2dbe11bf Merge remote-tracking branch 'origin/tapanito/lending-fix-amendment' into tapanito/vault-share-pricing 2026-03-18 18:43:00 +01:00
Vito Tumas
b5d25c5ab1 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-18 18:39:43 +01:00
Vito
7dcbb7b215 refactor: inline v2 math into SLE wrappers, remove public math::v2 namespace
The separate pure-math functions added unnecessary indirection since
they won't be unit-tested directly. Inline the arithmetic back into
the v2 SLE wrappers to match v1's pattern.
2026-03-18 18:38:16 +01:00
Vito Tumas
52afb76fd8 Merge branch 'tapanito/lending-fix-amendment' into tapanito/vault-share-pricing 2026-03-18 18:23:19 +01:00
Vito
57d31c3842 adds initial vault valuation hsare implementation 2026-03-17 20:23:03 +01:00
Vito
b45068b9dd refactor: separate vault share pricing into versioned namespaces
Move vault pricing helpers from xrpl namespace into xrpl::vault with
amendment-gated dispatch (fixLendingProtocolV1_1). Extract v2 pure math
into public xrpl::vault::math::v2 for unit testability. v1 logic is
intentionally left as-is to avoid risk since Single Asset Vault is
already released.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:07:23 +01:00
Vito Tumas
7222150095 refactor: Rename fixLendingProtocolV1_1 to featureLendingProtocolV1_1 (#6527)
Use XRPL_FEATURE macro instead of XRPL_FIX since
LendingProtocolV1_1 is a feature amendment, not a fix.
Update all references in VaultDelete and related tests.
2026-03-16 09:26:57 +01:00
Vito
a67da5c2ed Merge remote-tracking branch 'origin/develop' into tapanito/lending-fix-amendment 2026-03-09 11:34:59 +01:00
Vito Tumas
d2f23b2f5b Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-05 14:29:35 +01:00
Vito Tumas
4067e5025f Add rounding to Vault invariants (#6217)
Co-authored-by: Ed Hennis <ed@ripple.com>
2026-03-05 10:38:42 +01:00
Vito Tumas
ed4330a7d6 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-04 11:26:33 +01:00
Vito Tumas
feba605998 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 15:38:14 +01:00
Vito
b322097529 fixes formatting errors 2026-03-03 13:51:15 +01:00
Vito Tumas
e159d27373 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 13:48:37 +01:00
Vito Tumas
ba53026006 adds sfMemoData field to VaultDelete transaction (#6356)
* adds sfMemoData field to VaultDelete transaction
2026-02-26 14:13:29 +01:00
Vito Tumas
34773080df Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-25 13:44:20 +01:00
Vito Tumas
3c3bd75991 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-24 14:40:31 +01:00
Vito Tumas
7459fe454d Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-23 12:17:17 +01:00
Vito
106bf48725 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-18 18:29:08 +01:00
Vito Tumas
74c968d4e3 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-17 13:51:08 +01:00
Vito
167147281c Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-12 15:22:30 +01:00
Vito Tumas
ba60306610 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-11 17:46:20 +01:00
Vito Tumas
6674500896 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-10 11:48:23 +01:00
Vito
c5d7ebe93d restores missing linebreak 2026-02-05 10:24:14 +01:00
Ed Hennis
d0b5ca9dab Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-04 18:21:55 -04:00
Vito
5e51893e9b fixes a typo 2026-02-04 11:31:58 +01:00
Vito
3422c11d02 adds lending v1.1 fix amendment 2026-02-04 11:30:41 +01:00
26 changed files with 2489 additions and 347 deletions

View File

@@ -1,81 +1,156 @@
#pragma once
#include <xrpl/basics/Expected.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <memory>
#include <optional>
namespace xrpl::vault {
namespace xrpl {
/** From the perspective of a vault, return the number of shares to give
depositor when they offer a fixed amount of assets. Note, since shares are
MPT, this number is integral and always truncated in this calculation.
@param vault The vault SLE.
@param issuance The MPTokenIssuance SLE for the vault's shares.
@param assets The amount of assets to convert.
@return The number of shares, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
assetsToSharesDeposit(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& assets);
/** From the perspective of a vault, return the number of assets to take from
depositor when they receive a fixed amount of shares. Note, since shares are
MPT, they are always an integral number.
@param vault The vault SLE.
@param issuance The MPTokenIssuance SLE for the vault's shares.
@param shares The amount of shares to convert.
@return The number of assets, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
sharesToAssetsDeposit(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& shares);
/** Controls whether to truncate shares instead of rounding. */
enum class TruncateShares : bool { no = false, yes = true };
/** 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
shares are MPT this number is integral, and it will be rounded to nearest
unless explicitly requested to be truncated instead.
// Low-level v2 math — exposed for unit testing.
namespace detail {
@param vault The vault SLE.
@param issuance The MPTokenIssuance SLE for the vault's shares.
@param assets The amount of assets to convert.
@param truncate Whether to truncate instead of rounding.
[[nodiscard]] STAmount
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets);
@return The number of shares, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
[[nodiscard]] STAmount
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
[[nodiscard]] STAmount
assetsToSharesWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
TruncateShares truncate = TruncateShares::no);
/** From the perspective of a vault, return the number of assets to give the
depositor when they redeem a fixed amount of shares. Note, since shares are
MPT, they are always an integral number.
[[nodiscard]] STAmount
sharesToAssetsWithdraw(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
@param vault The vault SLE.
@param issuance The MPTokenIssuance SLE for the vault's shares.
@param shares The amount of shares to convert.
} // namespace detail
@return The number of assets, or nullopt on error.
*/
[[nodiscard]] std::optional<STAmount>
sharesToAssetsWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& shares);
// High-level API — orchestrates forward+reverse conversions, handles overflow.
} // namespace xrpl
/** The actual amounts exchanged after the forward+reverse round-trip.
* Both values reflect post-rounding quantities: assets is the amount
* transferred, shares is the number of share tokens created or destroyed.
*/
struct ExchangeResult
{
STAmount assets;
STAmount shares;
};
/**
* Computes the asset/share exchange for a vault deposit. Converts the
* requested asset amount to shares (forward), then back-calculates the
* actual assets consumed (reverse) to ensure the vault never takes more
* than offered.
*
* @param assets The asset amount the depositor is offering.
*
* @return {assetsDeposited, sharesCreated} on success. assetsDeposited
* is always <= assets. Returns tecPRECISION_LOSS if shares
* truncate to zero, tecPATH_DRY on overflow.
*/
[[nodiscard]] Expected<ExchangeResult, TER>
computeDeposit(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
beast::Journal j);
/**
* Computes the asset/share exchange for a withdrawal by asset amount.
* Converts the requested assets to shares, then back-calculates the
* actual assets returned.
*
* Note: due to banker's rounding on the intermediate share value, the
* returned assets may slightly exceed the requested amount. This is by
* design — the user burns more shares to receive proportionally more
* assets. The per-share price is preserved. See XLS-0065 §3.1.7.1.
*
* @param assets The asset amount the withdrawer is requesting.
*
* @return {assetsWithdrawn, sharesRedeemed} on success. Returns
* tecPRECISION_LOSS if shares truncate to zero, tecPATH_DRY
* on overflow.
*/
[[nodiscard]] Expected<ExchangeResult, TER>
computeWithdrawByAssets(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
beast::Journal j);
/**
* Computes the asset/share exchange for a withdrawal by share amount.
* Converts the given shares directly to assets.
*
* @param shares The number of shares the withdrawer is redeeming.
*
* @return {assetsWithdrawn, shares} on success. Returns tecPATH_DRY
* on overflow.
*/
[[nodiscard]] Expected<ExchangeResult, TER>
computeWithdrawByShares(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares,
beast::Journal j);
/**
* Computes the asset/share exchange for a clawback. Performs a two-pass
* computation: first converts the clawback amount to shares and back to
* assets. If the result exceeds assetsAvailable, re-computes with
* truncated shares to ensure the recovered amount stays within the limit.
*
* @param clawbackAmount The asset amount being clawed back.
* @param assetsAvailable Hard ceiling — recovered assets must not exceed
* this value.
*
* @return {assetsRecovered, sharesDestroyed} on success. assetsRecovered
* is always <= assetsAvailable. Returns tecPATH_DRY on overflow.
*/
[[nodiscard]] Expected<ExchangeResult, TER>
computeClawback(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& clawbackAmount,
Number const& assetsAvailable,
beast::Journal j);
/**
* Updates vault state when a loan is issued: reduces available assets by the
* borrowed amount and increases total assets by the yield (accrued interest).
* With featureLendingProtocolV1_1, also tracks the yield in InterestUnrealized
* and validates vault state after modification.
*
* @param view The ledger view to read issuance from and update.
* @param vault The vault SLE to modify. Must be of type ltVAULT.
* @param amount The principal to borrow. Must be > 0 and <= AssetsAvailable.
* @param yield The accrued interest to add. Must be >= 0. Added to both
* AssetsTotal and InterestUnrealized (v1_1 only).
* @param j Journal for error logging.
*
* @return tesSUCCESS on success, tecINTERNAL on invalid inputs or if the
* resulting vault state fails validation. The caller should validate
* inputs beforehand to return a user-facing error code.
*/
[[nodiscard]] TER
borrowFromVault(
ApplyView& view,
SLE::ref vault,
Number const& amount,
Number const& yield,
beast::Journal j);
} // namespace xrpl::vault

View File

@@ -42,8 +42,8 @@ private:
public:
using value_type = STAmount;
static int const cMinOffset = -96;
static int const cMaxOffset = 80;
static int constexpr cMinOffset = -96;
static int constexpr cMaxOffset = 80;
// Maximum native value supported by the code
constexpr static std::uint64_t cMinValue = 1'000'000'000'000'000ull;
@@ -739,6 +739,21 @@ canAdd(STAmount const& amt1, STAmount const& amt2);
bool
canSubtract(STAmount const& amt1, STAmount const& amt2);
/** Get the scale of a Number for a given asset.
*
* "scale" is similar to "exponent", but from the perspective of STAmount, which has different rules
* and mantissa ranges for determining the exponent than Number.
*
* @param number The Number to get the scale of.
* @param asset The asset to use for determining the scale.
* @return The scale of this Number for the given asset.
*/
inline int
scale(Number const& number, Asset const& asset)
{
return STAmount{asset, number}.exponent();
}
} // namespace xrpl
//------------------------------------------------------------------------------

View File

@@ -17,6 +17,7 @@
// Keep it sorted in reverse chronological order.
XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo)
XRPL_FEATURE(LendingProtocolV1_1, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ExpiredNFTokenOfferRemoval, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (BatchInnerSigs, Supported::no, VoteBehavior::DefaultNo)

View File

@@ -487,6 +487,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
{sfShareMPTID, soeREQUIRED},
{sfWithdrawalPolicy, soeREQUIRED},
{sfScale, soeDEFAULT},
{sfInterestUnrealized, soeDEFAULT},
// no SharesTotal ever (use MPTIssuance.sfOutstandingAmount)
// no PermissionedDomainID ever (use MPTIssuance.sfDomainID)
}))

View File

@@ -223,6 +223,7 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::sMD_NeedsAsset | SField::sMD_Default)
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::sMD_NeedsAsset | SField::sMD_Default)
TYPED_SFIELD(sfInterestUnrealized, NUMBER, 18, SField::sMD_NeedsAsset | SField::sMD_Default)
// int32
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -886,6 +886,7 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete,
mustDeleteAcct | destroyMPTIssuance | mustModifyVault,
({
{sfVaultID, soeREQUIRED},
{sfMemoData, soeOPTIONAL},
}))
/** This transaction trades assets for shares with a vault. */

View File

@@ -287,6 +287,30 @@ public:
{
return this->sle_->isFieldPresent(sfScale);
}
/**
* @brief Get sfInterestUnrealized (soeDEFAULT)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_NUMBER::type::value_type>
getInterestUnrealized() const
{
if (hasInterestUnrealized())
return this->sle_->at(sfInterestUnrealized);
return std::nullopt;
}
/**
* @brief Check if sfInterestUnrealized is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasInterestUnrealized() const
{
return this->sle_->isFieldPresent(sfInterestUnrealized);
}
};
/**
@@ -506,6 +530,17 @@ public:
return *this;
}
/**
* @brief Set sfInterestUnrealized (soeDEFAULT)
* @return Reference to this builder for method chaining.
*/
VaultBuilder&
setInterestUnrealized(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfInterestUnrealized] = value;
return *this;
}
/**
* @brief Build and return the completed Vault wrapper.
* @param index The ledger entry index.

View File

@@ -57,6 +57,32 @@ public:
{
return this->tx_->at(sfVaultID);
}
/**
* @brief Get sfMemoData (soeOPTIONAL)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_VL::type::value_type>
getMemoData() const
{
if (hasMemoData())
{
return this->tx_->at(sfMemoData);
}
return std::nullopt;
}
/**
* @brief Check if sfMemoData is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasMemoData() const
{
return this->tx_->isFieldPresent(sfMemoData);
}
};
/**
@@ -112,6 +138,17 @@ public:
return *this;
}
/**
* @brief Set sfMemoData (soeOPTIONAL)
* @return Reference to this builder for method chaining.
*/
VaultDeleteBuilder&
setMemoData(std::decay_t<typename SF_VL::type::value_type> const& value)
{
object_[sfMemoData] = value;
return *this;
}
/**
* @brief Build and return the VaultDelete wrapper.
* @param publicKey The public key for signing.

View File

@@ -8,6 +8,7 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <optional>
#include <unordered_map>
#include <vector>
@@ -47,6 +48,7 @@ class ValidVault
Number assetsAvailable = 0;
Number assetsMaximum = 0;
Number lossUnrealized = 0;
Number interestUnrealized = 0;
Vault static make(SLE const&);
};
@@ -60,11 +62,19 @@ class ValidVault
Shares static make(SLE const&);
};
public:
struct DeltaInfo final
{
Number delta = numZero;
std::optional<int> scale;
};
private:
std::vector<Vault> afterVault_ = {};
std::vector<Shares> afterMPTs_ = {};
std::vector<Vault> beforeVault_ = {};
std::vector<Shares> beforeMPTs_ = {};
std::unordered_map<uint256, Number> deltas_ = {};
std::unordered_map<uint256, DeltaInfo> deltas_ = {};
public:
void
@@ -72,6 +82,10 @@ public:
bool
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
// Compute the coarsest scale required to represent all numbers
[[nodiscard]] static std::int32_t
computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers);
};
} // namespace xrpl

View File

@@ -171,7 +171,7 @@ getAssetsTotalScale(SLE::const_ref vaultSle)
{
if (!vaultSle)
return Number::minExponent - 1; // LCOV_EXCL_LINE
return STAmount{vaultSle->at(sfAsset), vaultSle->at(sfAssetsTotal)}.exponent();
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
}
TER

View File

@@ -1,9 +1,10 @@
#include <xrpl/ledger/View.h>
//
#include <xrpl/basics/Expected.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>

View File

@@ -1,78 +1,148 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
//
#include <xrpl/basics/Number.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/st.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STNumber.h>
namespace xrpl {
namespace xrpl::vault {
[[nodiscard]] std::optional<STAmount>
assetsToSharesDeposit(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& assets)
namespace detail {
// Deposit uses NAV = assetsTotal - interestUnrealized (excludes loss).
// Withdraw uses NAV = assetsTotal - interestUnrealized - lossUnrealized.
// See XLS-0065 §3.1.7.1 "Share Valuation" for rationale.
STAmount
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets)
{
XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesDeposit : non-negative assets");
XRPL_ASSERT(
assets.asset() == vault->at(sfAsset),
"xrpl::assetsToSharesDeposit : assets and vault match");
if (assets.negative() || assets.asset() != vault->at(sfAsset))
return std::nullopt; // LCOV_EXCL_LINE
Number const assetTotal = vault->at(sfAssetsTotal);
auto const scale = vault->at(sfScale);
STAmount shares{vault->at(sfShareMPTID)};
if (assetTotal == 0)
return STAmount{
shares.asset(),
Number(assets.mantissa(), assets.exponent() + scale).truncate(),
};
Number const interestUnrealized = vault->at(sfInterestUnrealized);
Number const shareTotal = issuance->at(sfOutstandingAmount);
auto const netAssetValue = assetTotal - interestUnrealized;
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::assetsToSharesDeposit : positive NAV");
shares = ((shareTotal * assets) / netAssetValue).truncate();
return shares;
}
STAmount
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
{
Number const assetTotal = vault->at(sfAssetsTotal);
auto const scale = vault->at(sfScale);
STAmount assets{vault->at(sfAsset)};
if (assetTotal == 0)
return STAmount{
assets.asset(),
shares.mantissa(),
shares.exponent() - scale,
false,
};
Number const interestUnrealized = vault->at(sfInterestUnrealized);
Number const shareTotal = issuance->at(sfOutstandingAmount);
auto const netAssetValue = assetTotal - interestUnrealized;
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::sharesToAssetsDeposit : positive NAV");
assets = (netAssetValue * shares) / shareTotal;
return assets;
}
STAmount
assetsToSharesWithdraw(
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
TruncateShares truncate)
{
Number const assetTotal = vault->at(sfAssetsTotal);
Number const interestUnrealized = vault->at(sfInterestUnrealized);
Number const lossUnrealized = vault->at(sfLossUnrealized);
Number const netAssetValue = assetTotal - interestUnrealized - lossUnrealized;
STAmount shares{vault->at(sfShareMPTID)};
if (netAssetValue == 0)
return shares;
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::assetsToSharesWithdraw : positive NAV");
Number const shareTotal = issuance->at(sfOutstandingAmount);
Number result = (shareTotal * assets) / netAssetValue;
if (truncate == TruncateShares::yes)
result = result.truncate();
shares = result;
return shares;
}
STAmount
sharesToAssetsWithdraw(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
{
Number const assetTotal = vault->at(sfAssetsTotal);
Number const interestUnrealized = vault->at(sfInterestUnrealized);
Number const lossUnrealized = vault->at(sfLossUnrealized);
Number const netAssetValue = assetTotal - interestUnrealized - lossUnrealized;
STAmount assets{vault->at(sfAsset)};
if (netAssetValue == 0)
return assets;
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::sharesToAssetsWithdraw : positive NAV");
Number const shareTotal = issuance->at(sfOutstandingAmount);
assets = (netAssetValue * shares) / shareTotal;
return assets;
}
} // namespace detail
namespace {
namespace v1 {
STAmount
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets)
{
Number const assetTotal = vault->at(sfAssetsTotal);
STAmount shares{vault->at(sfShareMPTID)};
if (assetTotal == 0)
{
return STAmount{
shares.asset(),
Number(assets.mantissa(), assets.exponent() + vault->at(sfScale)).truncate()};
}
Number const shareTotal = issuance->at(sfOutstandingAmount);
shares = ((shareTotal * assets) / assetTotal).truncate();
return shares;
}
[[nodiscard]] std::optional<STAmount>
sharesToAssetsDeposit(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& shares)
STAmount
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
{
XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsDeposit : non-negative shares");
XRPL_ASSERT(
shares.asset() == vault->at(sfShareMPTID),
"xrpl::sharesToAssetsDeposit : shares and vault match");
if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
return std::nullopt; // LCOV_EXCL_LINE
Number const assetTotal = vault->at(sfAssetsTotal);
STAmount assets{vault->at(sfAsset)};
if (assetTotal == 0)
{
return STAmount{
assets.asset(), shares.mantissa(), shares.exponent() - vault->at(sfScale), false};
}
Number const shareTotal = issuance->at(sfOutstandingAmount);
assets = (assetTotal * shares) / shareTotal;
return assets;
}
[[nodiscard]] std::optional<STAmount>
STAmount
assetsToSharesWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
TruncateShares truncate)
{
XRPL_ASSERT(!assets.negative(), "xrpl::assetsToSharesWithdraw : non-negative assets");
XRPL_ASSERT(
assets.asset() == vault->at(sfAsset),
"xrpl::assetsToSharesWithdraw : assets and vault match");
if (assets.negative() || assets.asset() != vault->at(sfAsset))
return std::nullopt; // LCOV_EXCL_LINE
Number assetTotal = vault->at(sfAssetsTotal);
assetTotal -= vault->at(sfLossUnrealized);
STAmount shares{vault->at(sfShareMPTID)};
@@ -86,19 +156,9 @@ assetsToSharesWithdraw(
return shares;
}
[[nodiscard]] std::optional<STAmount>
sharesToAssetsWithdraw(
std::shared_ptr<SLE const> const& vault,
std::shared_ptr<SLE const> const& issuance,
STAmount const& shares)
STAmount
sharesToAssetsWithdraw(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
{
XRPL_ASSERT(!shares.negative(), "xrpl::sharesToAssetsWithdraw : non-negative shares");
XRPL_ASSERT(
shares.asset() == vault->at(sfShareMPTID),
"xrpl::sharesToAssetsWithdraw : shares and vault match");
if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
return std::nullopt; // LCOV_EXCL_LINE
Number assetTotal = vault->at(sfAssetsTotal);
assetTotal -= vault->at(sfLossUnrealized);
STAmount assets{vault->at(sfAsset)};
@@ -109,4 +169,391 @@ sharesToAssetsWithdraw(
return assets;
}
} // namespace xrpl
} // namespace v1
// v2 vault state validation — checks ledger invariants.
// Returns tecINTERNAL and logs on invalid state. Returns tesSUCCESS if valid.
TER
validateVaultState(SLE::const_ref vault, SLE::const_ref issuance, beast::Journal j)
{
Number const assetsTotal = vault->at(sfAssetsTotal);
if (assetsTotal == 0)
return tesSUCCESS;
auto const interestUnrealized = vault->at(sfInterestUnrealized);
auto const lossUnrealized = vault->at(sfLossUnrealized);
auto const assetsMaximum = vault->at(sfAssetsMaximum);
// Cannot exceed asset cap
if (assetsMaximum > 0 && assetsTotal > assetsMaximum)
{
JLOG(j.error()) << "vault state: assets total exceeds maximum"
<< " (assetsTotal=" << assetsTotal << ", assetsMaximum=" << assetsMaximum
<< ")";
return tecINTERNAL;
}
if (vault->at(sfAssetsAvailable) > assetsTotal)
{
JLOG(j.error()) << "vault state: assets available exceeds assets total"
<< " (assetsAvailable=" << vault->at(sfAssetsAvailable)
<< ", assetsTotal=" << assetsTotal << ")";
return tecINTERNAL;
}
if (interestUnrealized < 0)
{
JLOG(j.error()) << "vault state: interest unrealized is negative"
<< " (interestUnrealized=" << interestUnrealized << ")";
return tecINTERNAL;
}
// Interest can only accrue on lent assets (assetsTotal - assetsAvailable).
Number const lentAssets = assetsTotal - vault->at(sfAssetsAvailable);
if (interestUnrealized > lentAssets)
{
JLOG(j.error()) << "vault state: interest unrealized exceeds lent assets"
<< " (interestUnrealized=" << interestUnrealized
<< ", lentAssets=" << lentAssets << ")";
return tecINTERNAL;
}
// Deposit NAV excludes loss; withdrawal NAV excludes both.
// Deposit NAV <= 0 means all vault value is unrealized interest, which should be impossible.
Number const depositNAV = assetsTotal - interestUnrealized;
if (depositNAV <= 0)
{
JLOG(j.error()) << "vault state: deposit NAV <= 0"
<< " (assetsTotal=" << assetsTotal
<< ", interestUnrealized=" << interestUnrealized << ")";
return tecINTERNAL;
}
Number const withdrawNAV = depositNAV - lossUnrealized;
if (withdrawNAV < 0)
{
JLOG(j.error()) << "vault state: withdrawal NAV < 0"
<< " (assetsTotal=" << assetsTotal
<< ", interestUnrealized=" << interestUnrealized
<< ", lossUnrealized=" << lossUnrealized << ")";
return tecINTERNAL;
}
Number const shareTotal = issuance->at(sfOutstandingAmount);
if (withdrawNAV > 0 && shareTotal <= 0)
{
JLOG(j.error()) << "vault state: no outstanding shares with positive NAV"
<< " (NAV=" << withdrawNAV << ", shareTotal=" << shareTotal << ")";
return tecINTERNAL;
}
return tesSUCCESS;
}
STAmount
assetsToSharesDeposit(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets)
{
if (rules.enabled(featureLendingProtocolV1_1))
return detail::assetsToSharesDeposit(vault, issuance, assets);
return v1::assetsToSharesDeposit(vault, issuance, assets);
}
STAmount
sharesToAssetsDeposit(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares)
{
if (rules.enabled(featureLendingProtocolV1_1))
return detail::sharesToAssetsDeposit(vault, issuance, shares);
return v1::sharesToAssetsDeposit(vault, issuance, shares);
}
STAmount
assetsToSharesWithdraw(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
TruncateShares truncate = TruncateShares::no)
{
if (rules.enabled(featureLendingProtocolV1_1))
return detail::assetsToSharesWithdraw(vault, issuance, assets, truncate);
return v1::assetsToSharesWithdraw(vault, issuance, assets, truncate);
}
STAmount
sharesToAssetsWithdraw(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares)
{
if (rules.enabled(featureLendingProtocolV1_1))
return detail::sharesToAssetsWithdraw(vault, issuance, shares);
return v1::sharesToAssetsWithdraw(vault, issuance, shares);
}
} // anonymous namespace
[[nodiscard]] Expected<ExchangeResult, TER>
computeDeposit(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
beast::Journal j)
{
XRPL_ASSERT(vault->getType() == ltVAULT, "xrpl::vault::computeDeposit : vault SLE");
XRPL_ASSERT(
issuance->getType() == ltMPTOKEN_ISSUANCE, "xrpl::vault::computeDeposit : issuance SLE");
if (assets.negative())
{
JLOG(j.error()) << "computeDeposit: negative assets";
return Unexpected(tecINTERNAL);
}
if (assets.asset() != vault->at(sfAsset))
{
JLOG(j.error()) << "computeDeposit: asset mismatch";
return Unexpected(tecINTERNAL);
}
if (rules.enabled(featureLendingProtocolV1_1))
{
if (auto const ter = validateVaultState(vault, issuance, j))
return Unexpected(ter);
}
try
{
auto const shares = assetsToSharesDeposit(rules, vault, issuance, assets);
if (shares == beast::zero)
return Unexpected(tecPRECISION_LOSS);
auto const assetsOut = sharesToAssetsDeposit(rules, vault, issuance, shares);
if (assetsOut > assets)
{
// LCOV_EXCL_START
JLOG(j.error()) << "computeDeposit: would take more than offered.";
return Unexpected(tecINTERNAL);
// LCOV_EXCL_STOP
}
return ExchangeResult{assetsOut, shares};
}
catch (std::overflow_error const&)
{
JLOG(j.debug()) << "computeDeposit: overflow error with"
<< " scale=" << vault->at(sfScale)
<< ", assetsTotal=" << vault->at(sfAssetsTotal)
<< ", sharesTotal=" << issuance->at(sfOutstandingAmount)
<< ", amount=" << assets;
return Unexpected(tecPATH_DRY);
}
}
[[nodiscard]] Expected<ExchangeResult, TER>
computeWithdrawByAssets(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
beast::Journal j)
{
XRPL_ASSERT(vault->getType() == ltVAULT, "xrpl::vault::computeWithdrawByAssets : vault SLE");
XRPL_ASSERT(
issuance->getType() == ltMPTOKEN_ISSUANCE,
"xrpl::vault::computeWithdrawByAssets : issuance SLE");
if (assets.negative())
{
JLOG(j.error()) << "computeWithdrawByAssets: negative assets";
return Unexpected(tecINTERNAL);
}
if (assets.asset() != vault->at(sfAsset))
{
JLOG(j.error()) << "computeWithdrawByAssets: asset mismatch";
return Unexpected(tecINTERNAL);
}
if (rules.enabled(featureLendingProtocolV1_1))
{
if (auto const ter = validateVaultState(vault, issuance, j))
return Unexpected(ter);
}
try
{
auto const shares = assetsToSharesWithdraw(rules, vault, issuance, assets);
if (shares == beast::zero)
return Unexpected(tecPRECISION_LOSS);
auto const assetsOut = sharesToAssetsWithdraw(rules, vault, issuance, shares);
return ExchangeResult{assetsOut, shares};
}
catch (std::overflow_error const&)
{
JLOG(j.debug()) << "computeWithdrawByAssets: overflow error with"
<< " scale=" << vault->at(sfScale)
<< ", assetsTotal=" << vault->at(sfAssetsTotal)
<< ", sharesTotal=" << issuance->at(sfOutstandingAmount)
<< ", amount=" << assets;
return Unexpected(tecPATH_DRY);
}
}
[[nodiscard]] Expected<ExchangeResult, TER>
computeWithdrawByShares(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares,
beast::Journal j)
{
XRPL_ASSERT(vault->getType() == ltVAULT, "xrpl::vault::computeWithdrawByShares : vault SLE");
XRPL_ASSERT(
issuance->getType() == ltMPTOKEN_ISSUANCE,
"xrpl::vault::computeWithdrawByShares : issuance SLE");
if (shares.negative())
{
JLOG(j.error()) << "computeWithdrawByShares: negative shares";
return Unexpected(tecINTERNAL);
}
if (shares.asset() != vault->at(sfShareMPTID))
{
JLOG(j.error()) << "computeWithdrawByShares: share asset mismatch";
return Unexpected(tecINTERNAL);
}
if (rules.enabled(featureLendingProtocolV1_1))
{
if (auto const ter = validateVaultState(vault, issuance, j))
return Unexpected(ter);
}
try
{
auto const assets = sharesToAssetsWithdraw(rules, vault, issuance, shares);
return ExchangeResult{assets, shares};
}
catch (std::overflow_error const&)
{
JLOG(j.debug()) << "computeWithdrawByShares: overflow error with"
<< " scale=" << vault->at(sfScale)
<< ", assetsTotal=" << vault->at(sfAssetsTotal)
<< ", sharesTotal=" << issuance->at(sfOutstandingAmount)
<< ", shares=" << shares;
return Unexpected(tecPATH_DRY);
}
}
[[nodiscard]] Expected<ExchangeResult, TER>
computeClawback(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& clawbackAmount,
Number const& assetsAvailable,
beast::Journal j)
{
XRPL_ASSERT(vault->getType() == ltVAULT, "xrpl::vault::computeClawback : vault SLE");
XRPL_ASSERT(
issuance->getType() == ltMPTOKEN_ISSUANCE, "xrpl::vault::computeClawback : issuance SLE");
if (clawbackAmount.negative())
{
JLOG(j.error()) << "computeClawback: negative clawbackAmount";
return Unexpected(tecINTERNAL);
}
if (clawbackAmount.asset() != vault->at(sfAsset))
{
JLOG(j.error()) << "computeClawback: asset mismatch";
return Unexpected(tecINTERNAL);
}
if (rules.enabled(featureLendingProtocolV1_1))
{
if (auto const ter = validateVaultState(vault, issuance, j))
return Unexpected(ter);
}
try
{
auto sharesDestroyed = assetsToSharesWithdraw(rules, vault, issuance, clawbackAmount);
auto assetsRecovered = sharesToAssetsWithdraw(rules, vault, issuance, sharesDestroyed);
// Clamp to maximum.
if (assetsRecovered > assetsAvailable)
{
assetsRecovered = STAmount{assetsRecovered.asset(), assetsAvailable};
// Note, it is important to truncate the number of shares,
// otherwise the corresponding assets might breach the
// AssetsAvailable
sharesDestroyed = assetsToSharesWithdraw(
rules, vault, issuance, assetsRecovered, TruncateShares::yes);
assetsRecovered = sharesToAssetsWithdraw(rules, vault, issuance, sharesDestroyed);
if (assetsRecovered > assetsAvailable)
{
// LCOV_EXCL_START
JLOG(j.error()) << "computeClawback: invalid rounding of shares.";
return Unexpected(tecINTERNAL);
// LCOV_EXCL_STOP
}
}
return ExchangeResult{assetsRecovered, sharesDestroyed};
}
catch (std::overflow_error const&)
{
JLOG(j.debug()) << "computeClawback: overflow error with"
<< " scale=" << vault->at(sfScale)
<< ", assetsTotal=" << vault->at(sfAssetsTotal)
<< ", sharesTotal=" << issuance->at(sfOutstandingAmount)
<< ", amount=" << clawbackAmount;
return Unexpected(tecPATH_DRY);
}
}
[[nodiscard]] TER
borrowFromVault(
ApplyView& view,
SLE::ref vault,
Number const& amount,
Number const& yield,
beast::Journal j)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::vault::borrowFromVault : vault SLE");
if (amount <= 0 || yield < 0)
{
JLOG(j.error()) << "borrowFromVault: invalid input"
<< " (amount=" << amount << ", yield=" << yield << ")";
return tecINTERNAL;
}
// Cannot borrow more than available assets
if (vault->at(sfAssetsAvailable) < amount)
{
JLOG(j.error()) << "borrowFromVault: insufficient available assets"
<< " (available=" << *vault->at(sfAssetsAvailable) << ", amount=" << amount
<< ")";
return tecINTERNAL;
}
// Update vault state
if (view.rules().enabled(featureLendingProtocolV1_1))
vault->at(sfInterestUnrealized) += yield;
vault->at(sfAssetsAvailable) -= amount;
vault->at(sfAssetsTotal) += yield;
if (view.rules().enabled(featureLendingProtocolV1_1))
{
std::shared_ptr<SLE const> issuance =
view.read(keylet::mptIssuance(vault->at(sfShareMPTID)));
if (!issuance)
return tecINTERNAL; // LCOV_EXCL_LINE
if (auto const ter = validateVaultState(vault, issuance, j))
return ter;
}
view.update(vault);
return tesSUCCESS;
}
} // namespace xrpl::vault

View File

@@ -9,6 +9,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
@@ -30,6 +31,7 @@ ValidVault::Vault::make(SLE const& from)
self.assetsAvailable = from.at(sfAssetsAvailable);
self.assetsMaximum = from.at(sfAssetsMaximum);
self.lossUnrealized = from.at(sfLossUnrealized);
self.interestUnrealized = from.at(sfInterestUnrealized);
return self;
}
@@ -61,10 +63,12 @@ ValidVault::visitEntry(
"xrpl::ValidVault::visitEntry : some object is available");
// Number balanceDelta will capture the difference (delta) between "before"
// state (zero if created) and "after" state (zero if destroyed), so the
// invariants can validate that the change in account balances matches the
// change in vault balances, stored to deltas_ at the end of this function.
Number balanceDelta{};
// state (zero if created) and "after" state (zero if destroyed), and
// preserves value scale (exponent) to round values to the same scale during
// validation. It is used to validate that the change in account
// balances matches the change in vault balances, stored to deltas_ at the
// end of this function.
DeltaInfo balanceDelta{numZero, std::nullopt};
std::int8_t sign = 0;
if (before)
@@ -78,18 +82,34 @@ ValidVault::visitEntry(
// At this moment we have no way of telling if this object holds
// vault shares or something else. Save it for finalize.
beforeMPTs_.push_back(Shares::make(*before));
balanceDelta = static_cast<std::int64_t>(before->getFieldU64(sfOutstandingAmount));
balanceDelta.delta =
static_cast<std::int64_t>(before->getFieldU64(sfOutstandingAmount));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = 1;
break;
case ltMPTOKEN:
balanceDelta = static_cast<std::int64_t>(before->getFieldU64(sfMPTAmount));
balanceDelta.delta = static_cast<std::int64_t>(before->getFieldU64(sfMPTAmount));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltACCOUNT_ROOT:
case ltRIPPLE_STATE:
balanceDelta = before->getFieldAmount(sfBalance);
balanceDelta.delta = before->getFieldAmount(sfBalance);
// Account balance is XRP, which is an int, so the scale is
// always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltRIPPLE_STATE: {
auto const amount = before->getFieldAmount(sfBalance);
balanceDelta.delta = amount;
// Trust Line balances are STAmounts, so we can use the exponent
// directly to get the scale.
balanceDelta.scale = amount.exponent();
sign = -1;
break;
}
default:;
}
}
@@ -105,19 +125,36 @@ ValidVault::visitEntry(
// At this moment we have no way of telling if this object holds
// vault shares or something else. Save it for finalize.
afterMPTs_.push_back(Shares::make(*after));
balanceDelta -=
balanceDelta.delta -=
Number(static_cast<std::int64_t>(after->getFieldU64(sfOutstandingAmount)));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = 1;
break;
case ltMPTOKEN:
balanceDelta -= Number(static_cast<std::int64_t>(after->getFieldU64(sfMPTAmount)));
balanceDelta.delta -=
Number(static_cast<std::int64_t>(after->getFieldU64(sfMPTAmount)));
// MPTs are ints, so the scale is always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltACCOUNT_ROOT:
case ltRIPPLE_STATE:
balanceDelta -= Number(after->getFieldAmount(sfBalance));
balanceDelta.delta -= Number(after->getFieldAmount(sfBalance));
// Account balance is XRP, which is an int, so the scale is
// always 0.
balanceDelta.scale = 0;
sign = -1;
break;
case ltRIPPLE_STATE: {
auto const amount = after->getFieldAmount(sfBalance);
balanceDelta.delta -= Number(amount);
// Trust Line balances are STAmounts, so we can use the exponent
// directly to get the scale.
if (amount.exponent() > balanceDelta.scale)
balanceDelta.scale = amount.exponent();
sign = -1;
break;
}
default:;
}
}
@@ -129,7 +166,11 @@ ValidVault::visitEntry(
// transferred to the account. We intentionally do not compare balanceDelta
// against zero, to avoid missing such updates.
if (sign != 0)
deltas_[key] = balanceDelta * sign;
{
XRPL_ASSERT_PARTS(balanceDelta.scale, "xrpl::ValidVault::visitEntry", "scale initialized");
balanceDelta.delta *= sign;
deltas_[key] = balanceDelta;
}
}
bool
@@ -347,6 +388,48 @@ ValidVault::finalize(
result = false;
}
if (view.rules().enabled(featureLendingProtocolV1_1))
{
if (afterVault.interestUnrealized < zero)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized must be non-negative";
result = false;
}
// InterestUnrealized <= AssetsTotal - AssetsAvailable (the lent portion)
if (afterVault.interestUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized exceeds lent assets";
result = false;
}
// Deposit net asset value = AssetsTotal - InterestUnrealized must be > 0 when vault
// has assets.
if (afterVault.assetsTotal > zero &&
afterVault.assetsTotal - afterVault.interestUnrealized <= zero)
{
JLOG(j.fatal()) << "Invariant failed: deposit net asset value must be positive";
result = false;
}
// Withdrawal net asset value = AssetsTotal - InterestUnrealized - LossUnrealized
// must not go negative.
if (afterVault.assetsTotal - afterVault.interestUnrealized - afterVault.lossUnrealized <
zero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal net asset value must not be negative";
result = false;
}
// If there's unrealized interest, shares must be outstanding.
if (updatedShares && afterVault.interestUnrealized > zero &&
updatedShares->sharesTotal == 0)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized with no outstanding shares";
result = false;
}
}
// Thanks to this check we can simply do `assert(!beforeVault_.empty()` when
// enforcing invariants on transaction types other than ttVAULT_CREATE
if (beforeVault_.empty() && txnType != ttVAULT_CREATE)
@@ -361,8 +444,17 @@ ValidVault::finalize(
txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY)
{
JLOG(j.fatal()) << //
"Invariant failed: vault transaction must not change loss "
"unrealized";
"Invariant failed: vault transaction must not change loss unrealized";
result = false;
}
// Interest unrealized must only change via loan transactions.
if (view.rules().enabled(featureLendingProtocolV1_1) && !beforeVault_.empty() &&
afterVault.interestUnrealized != beforeVault_[0].interestUnrealized &&
txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY && txnType != ttLOAN_SET)
{
JLOG(j.fatal()) << //
"Invariant failed: vault transaction must not change interest unrealized";
result = false;
}
@@ -391,13 +483,13 @@ ValidVault::finalize(
}
auto const& vaultAsset = afterVault.asset;
auto const deltaAssets = [&](AccountID const& id) -> std::optional<Number> {
auto const deltaAssets = [&](AccountID const& id) -> std::optional<DeltaInfo> {
auto const get = //
[&](auto const& it, std::int8_t sign = 1) -> std::optional<Number> {
[&](auto const& it, std::int8_t sign = 1) -> std::optional<DeltaInfo> {
if (it == deltas_.end())
return std::nullopt;
return it->second * sign;
return DeltaInfo{it->second.delta * sign, it->second.scale};
};
return std::visit(
@@ -416,7 +508,7 @@ ValidVault::finalize(
},
vaultAsset.value());
};
auto const deltaAssetsTxAccount = [&]() -> std::optional<Number> {
auto const deltaAssetsTxAccount = [&]() -> std::optional<DeltaInfo> {
auto ret = deltaAssets(tx[sfAccount]);
// Nothing returned or not XRP transaction
if (!ret.has_value() || !vaultAsset.native())
@@ -427,20 +519,20 @@ ValidVault::finalize(
delegate.has_value() && *delegate != tx[sfAccount])
return ret;
*ret += fee.drops();
if (*ret == zero)
ret->delta += fee.drops();
if (ret->delta == zero)
return std::nullopt;
return ret;
};
auto const deltaShares = [&](AccountID const& id) -> std::optional<Number> {
auto const deltaShares = [&](AccountID const& id) -> std::optional<DeltaInfo> {
auto const it = [&]() {
if (id == afterVault.pseudoId)
return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
}();
return it != deltas_.end() ? std::optional<Number>(it->second) : std::nullopt;
return it != deltas_.end() ? std::optional<DeltaInfo>(it->second) : std::nullopt;
};
auto const vaultHoldsNoAssets = [&](Vault const& vault) {
@@ -567,16 +659,38 @@ ValidVault::finalize(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault");
auto const& beforeVault = beforeVault_[0];
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!vaultDeltaAssets)
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault balance";
return false; // That's all we can do
}
if (*vaultDeltaAssets > tx[sfAmount])
// Get the coarsest scale to round calculations to
DeltaInfo totalDelta{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
DeltaInfo availableDelta{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset,
{
*maybeVaultDeltaAssets,
totalDelta,
availableDelta,
});
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
auto const txAmount = roundToAsset(vaultAsset, tx[sfAmount], minScale);
if (vaultDeltaAssets > txAmount)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must not change vault "
@@ -584,7 +698,7 @@ ValidVault::finalize(
result = false;
}
if (*vaultDeltaAssets <= zero)
if (vaultDeltaAssets <= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase vault balance";
@@ -601,16 +715,23 @@ ValidVault::finalize(
if (!issuerDeposit)
{
auto const accountDeltaAssets = deltaAssetsTxAccount();
if (!accountDeltaAssets)
auto const maybeAccDeltaAssets = deltaAssetsTxAccount();
if (!maybeAccDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor "
"balance";
return false;
}
auto const localMinScale =
std::max(minScale, computeMinScale(vaultAsset, {*maybeAccDeltaAssets}));
if (*accountDeltaAssets >= zero)
auto const accountDeltaAssets =
roundToAsset(vaultAsset, maybeAccDeltaAssets->delta, localMinScale);
auto const localVaultDeltaAssets =
roundToAsset(vaultAsset, vaultDeltaAssets, localMinScale);
if (accountDeltaAssets >= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must decrease depositor "
@@ -618,7 +739,7 @@ ValidVault::finalize(
result = false;
}
if (*accountDeltaAssets * -1 != *vaultDeltaAssets)
if (localVaultDeltaAssets * -1 != accountDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault and "
@@ -636,16 +757,17 @@ ValidVault::finalize(
result = false;
}
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
auto const maybeAccDeltaShares = deltaShares(tx[sfAccount]);
if (!maybeAccDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor "
"shares";
return false; // That's all we can do
}
if (*accountDeltaShares <= zero)
// We don't need to round shares, they are integral MPT
auto const& accountDeltaShares = *maybeAccDeltaShares;
if (accountDeltaShares.delta <= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase depositor "
@@ -653,15 +775,17 @@ ValidVault::finalize(
result = false;
}
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || *vaultDeltaShares == zero)
auto const maybeVaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault shares";
return false; // That's all we can do
}
if (*vaultDeltaShares * -1 != *accountDeltaShares)
// We don't need to round shares, they are integral MPT
auto const& vaultDeltaShares = *maybeVaultDeltaShares;
if (vaultDeltaShares.delta * -1 != accountDeltaShares.delta)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor and "
@@ -669,13 +793,18 @@ ValidVault::finalize(
result = false;
}
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetTotalDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
"outstanding must add up";
result = false;
}
if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable)
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
"available must add up";
@@ -693,16 +822,33 @@ ValidVault::finalize(
"vault");
auto const& beforeVault = beforeVault_[0];
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!vaultDeltaAssets)
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
"change vault balance";
return false; // That's all we can do
}
if (*vaultDeltaAssets >= zero)
// Get the most coarse scale to round calculations to
auto const totalDelta = DeltaInfo{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
auto const availableDelta = DeltaInfo{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
auto const vaultPseudoDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultPseudoDeltaAssets >= zero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
"decrease vault balance";
@@ -720,15 +866,15 @@ ValidVault::finalize(
if (!issuerWithdrawal)
{
auto const accountDeltaAssets = deltaAssetsTxAccount();
auto const otherAccountDelta = [&]() -> std::optional<Number> {
auto const maybeAccDelta = deltaAssetsTxAccount();
auto const maybeOtherAccDelta = [&]() -> std::optional<DeltaInfo> {
if (auto const destination = tx[~sfDestination];
destination && *destination != tx[sfAccount])
return deltaAssets(*destination);
return std::nullopt;
}();
if (accountDeltaAssets.has_value() == otherAccountDelta.has_value())
if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change one "
@@ -737,9 +883,17 @@ ValidVault::finalize(
}
auto const destinationDelta = //
accountDeltaAssets ? *accountDeltaAssets : *otherAccountDelta;
maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
if (destinationDelta <= zero)
// the scale of destinationDelta can be coarser than
// minScale, so we take that into account when rounding
auto const localMinScale =
std::max(minScale, computeMinScale(vaultAsset, {destinationDelta}));
auto const roundedDestinationDelta =
roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
if (roundedDestinationDelta <= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must increase "
@@ -747,7 +901,9 @@ ValidVault::finalize(
result = false;
}
if (*vaultDeltaAssets * -1 != destinationDelta)
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
if (localPseudoDeltaAssets * -1 != roundedDestinationDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change vault "
@@ -756,6 +912,7 @@ ValidVault::finalize(
}
}
// We don't need to round shares, they are integral MPT
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
{
@@ -765,7 +922,7 @@ ValidVault::finalize(
return false;
}
if (*accountDeltaShares >= zero)
if (accountDeltaShares->delta >= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must decrease depositor "
@@ -773,15 +930,16 @@ ValidVault::finalize(
result = false;
}
// We don't need to round shares, they are integral MPT
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || *vaultDeltaShares == zero)
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change vault shares";
return false; // That's all we can do
}
if (*vaultDeltaShares * -1 != *accountDeltaShares)
if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change depositor "
@@ -789,15 +947,20 @@ ValidVault::finalize(
result = false;
}
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// Note, vaultBalance is negative (see check above)
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
if (assetTotalDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
"assets outstanding must add up";
result = false;
}
if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable)
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
"assets available must add up";
@@ -828,10 +991,24 @@ ValidVault::finalize(
}
}
auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (vaultDeltaAssets)
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (maybeVaultDeltaAssets)
{
if (*vaultDeltaAssets >= zero)
auto const totalDelta = DeltaInfo{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
auto const availableDelta = DeltaInfo{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultDeltaAssets >= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must decrease vault "
@@ -839,7 +1016,9 @@ ValidVault::finalize(
result = false;
}
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetsTotalDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets outstanding "
@@ -847,8 +1026,11 @@ ValidVault::finalize(
result = false;
}
if (beforeVault.assetsAvailable + *vaultDeltaAssets !=
afterVault.assetsAvailable)
auto const assetAvailableDelta = roundToAsset(
vaultAsset,
afterVault.assetsAvailable - beforeVault.assetsAvailable,
minScale);
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets available "
@@ -863,15 +1045,15 @@ ValidVault::finalize(
return false; // That's all we can do
}
auto const accountDeltaShares = deltaShares(tx[sfHolder]);
if (!accountDeltaShares)
// We don't need to round shares, they are integral MPT
auto const maybeAccountDeltaShares = deltaShares(tx[sfHolder]);
if (!maybeAccountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change holder shares";
return false; // That's all we can do
}
if (*accountDeltaShares >= zero)
if (maybeAccountDeltaShares->delta >= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must decrease holder "
@@ -879,15 +1061,16 @@ ValidVault::finalize(
result = false;
}
// We don't need to round shares, they are integral MPT
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || *vaultDeltaShares == zero)
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change vault shares";
return false; // That's all we can do
}
if (*vaultDeltaShares * -1 != *accountDeltaShares)
if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change holder and "
@@ -924,4 +1107,19 @@ ValidVault::finalize(
return true;
}
[[nodiscard]] std::int32_t
ValidVault::computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers)
{
if (numbers.size() == 0)
return 0;
auto const max =
std::max_element(numbers.begin(), numbers.end(), [](auto const& a, auto const& b) -> bool {
return a.scale < b.scale;
});
XRPL_ASSERT_PARTS(
max->scale, "xrpl::ValidVault::computeMinScale", "scale set for destinationDelta");
return max->scale.value_or(STAmount::cMaxOffset);
}
} // namespace xrpl

View File

@@ -123,7 +123,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
return roundToAsset(
vaultAsset,
tenthBipsOfValue(currentDebtTotal, TenthBips32(sleBroker->at(sfCoverRateMinimum))),
currentDebtTotal.exponent());
scale(currentDebtTotal, vaultAsset));
}();
if (coverAvail < amount)
return tecINSUFFICIENT_FUNDS;

View File

@@ -1,3 +1,5 @@
#include <xrpl/tx/transactors/vault/VaultClawback.h>
//
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
@@ -10,7 +12,6 @@
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/transactors/vault/VaultClawback.h>
#include <optional>
@@ -225,6 +226,7 @@ VaultClawback::assetsToClawback(
auto const assetsAvailable = vault->at(sfAssetsAvailable);
auto const mptIssuanceID = *vault->at(sfShareMPTID);
MPTIssue const share{mptIssuanceID};
auto const& rules = ctx_.view().rules();
if (clawbackAmount == beast::zero)
{
@@ -235,73 +237,19 @@ VaultClawback::assetsToClawback(
FreezeHandling::fhIGNORE_FREEZE,
AuthHandling::ahIGNORE_AUTH,
j_);
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
if (!maybeAssets)
return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE
auto const result =
vault::computeWithdrawByShares(rules, vault, sleShareIssuance, sharesDestroyed, j_);
if (!result)
return Unexpected(result.error());
return std::make_pair(*maybeAssets, sharesDestroyed);
return std::make_pair(result->assets, result->shares);
}
STAmount sharesDestroyed;
STAmount assetsRecovered = clawbackAmount;
try
{
{
auto const maybeShares =
assetsToSharesWithdraw(vault, sleShareIssuance, assetsRecovered);
if (!maybeShares)
return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;
}
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
if (!maybeAssets)
return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;
// Clamp to maximum.
if (assetsRecovered > *assetsAvailable)
{
assetsRecovered = *assetsAvailable;
// Note, it is important to truncate the number of shares,
// otherwise the corresponding assets might breach the
// AssetsAvailable
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleShareIssuance, assetsRecovered, TruncateShares::yes);
if (!maybeShares)
return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;
}
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
if (!maybeAssets)
return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;
if (assetsRecovered > *assetsAvailable)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "VaultClawback: invalid rounding of shares.";
return Unexpected(tecINTERNAL);
// LCOV_EXCL_STOP
}
}
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough
// Scale so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultClawback: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount)
<< ", amount=" << clawbackAmount.value();
return Unexpected(tecPATH_DRY);
}
return std::make_pair(assetsRecovered, sharesDestroyed);
auto const result = vault::computeClawback(
rules, vault, sleShareIssuance, clawbackAmount, Number{assetsAvailable}, j_);
if (!result)
return Unexpected(result.error());
return std::make_pair(result->assets, result->shares);
}
TER

View File

@@ -21,6 +21,13 @@ VaultDelete::preflight(PreflightContext const& ctx)
return temMALFORMED;
}
if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
return temDISABLED;
// The sfMemoData field is an optional field used to record the deletion reason.
if (auto const data = ctx.tx[~sfMemoData]; data && !validDataLength(data, maxDataPayloadLength))
return temMALFORMED;
return tesSUCCESS;
}

View File

@@ -1,4 +1,5 @@
#include <xrpl/ledger/View.h>
#include <xrpl/tx/transactors/vault/VaultDeposit.h>
//
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
@@ -13,7 +14,6 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/transactors/token/MPTokenAuthorize.h>
#include <xrpl/tx/transactors/vault/VaultDeposit.h>
namespace xrpl {
@@ -186,44 +186,12 @@ VaultDeposit::doApply()
}
}
STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
try
{
// Compute exchange before transferring any amounts.
{
auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesCreated = *maybeShares;
}
if (sharesCreated == beast::zero)
return tecPRECISION_LOSS;
auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
if (!maybeAssets)
{
return tecINTERNAL; // LCOV_EXCL_LINE
}
if (*maybeAssets > amount)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "VaultDeposit: would take more than offered.";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
assetsDeposited = *maybeAssets;
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultDeposit: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) << ", amount=" << amount;
return tecPATH_DRY;
}
// Compute exchange before transferring any amounts.
auto const& rules = ctx_.view().rules();
auto const result = vault::computeDeposit(rules, vault, sleIssuance, amount, j_);
if (!result)
return result.error();
auto const& [assetsDeposited, sharesCreated] = *result;
XRPL_ASSERT(
sharesCreated.asset() != assetsDeposited.asset(),

View File

@@ -1,3 +1,5 @@
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
//
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
@@ -9,7 +11,6 @@
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
namespace xrpl {
@@ -115,53 +116,18 @@ VaultWithdraw::doApply()
Asset const vaultAsset = vault->at(sfAsset);
MPTIssue const share{mptIssuanceID};
STAmount sharesRedeemed = {share};
STAmount assetsWithdrawn;
try
{
if (amount.asset() == vaultAsset)
{
// Fixed assets, variable shares.
{
auto const maybeShares = assetsToSharesWithdraw(vault, sleIssuance, amount);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesRedeemed = *maybeShares;
}
auto const& rules = ctx_.view().rules();
if (sharesRedeemed == beast::zero)
return tecPRECISION_LOSS;
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
assetsWithdrawn = *maybeAssets;
}
else if (amount.asset() == share)
{
// Fixed shares, variable assets.
sharesRedeemed = amount;
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
assetsWithdrawn = *maybeAssets;
}
else
{
return tefINTERNAL; // LCOV_EXCL_LINE
}
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultWithdraw: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount.value();
return tecPATH_DRY;
}
auto const result = [&]() -> Expected<vault::ExchangeResult, TER> {
if (amount.asset() == vaultAsset)
return vault::computeWithdrawByAssets(rules, vault, sleIssuance, amount, j_);
if (amount.asset() == share)
return vault::computeWithdrawByShares(rules, vault, sleIssuance, amount, j_);
return Unexpected(tefINTERNAL); // LCOV_EXCL_LINE
}();
if (!result)
return result.error();
auto const& [assetsWithdrawn, sharesRedeemed] = *result;
if (accountHolds(
view(),

View File

@@ -19,9 +19,14 @@
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/apply.h>
#include <xrpl/tx/invariants/InvariantCheck.h>
#include <boost/algorithm/string/predicate.hpp>
#include <initializer_list>
#include <string>
#include <vector>
namespace xrpl {
namespace test {
@@ -2271,6 +2276,7 @@ class Invariants_test : public beast::unit_test::suite
std::optional<int> assetsMaximum = std::nullopt;
std::optional<int> sharesTotal = std::nullopt;
std::optional<int> vaultAssets = std::nullopt;
std::optional<int> interestUnrealized = std::nullopt;
std::optional<AccountAmount> accountAssets = std::nullopt;
std::optional<AccountAmount> accountShares = std::nullopt;
// NOLINTEND(readability-redundant-member-init)
@@ -2285,11 +2291,13 @@ class Invariants_test : public beast::unit_test::suite
if (!sleShares)
return false;
// These two fields are adjusted in absolute terms
// These three fields are adjusted in absolute terms
if (args.lossUnrealized)
(*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
if (args.assetsMaximum)
(*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
if (args.interestUnrealized)
(*sleVault)[sfInterestUnrealized] = *args.interestUnrealized;
// Remaining fields are adjusted in terms of difference
if (args.assetsTotal)
@@ -2950,6 +2958,126 @@ class Invariants_test : public beast::unit_test::suite
precloseXrp,
TxAccount::A2);
doInvariantCheck(
{"interest unrealized must be non-negative"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.interestUnrealized = -1;
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// InterestUnrealized exceeds the lent portion (assetsTotal -
// assetsAvailable). Vault has 30 XRP total, reduce available by 10 XRP
// so lent = 10 XRP, then set interest to 20 XRP which exceeds lent.
doInvariantCheck(
{"interest unrealized exceeds lent assets"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 20).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Deposit net asset value = assetsTotal - interestUnrealized must be > 0 when
// vault has assets. Set interestUnrealized = assetsTotal so net asset value = 0.
doInvariantCheck(
{"deposit net asset value must be positive"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -20).value();
adj.interestUnrealized = (DROPS_PER_XRP * 30 + 10).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Withdrawal net asset value = assetsTotal - interestUnrealized - lossUnrealized
// must not go negative.
doInvariantCheck(
{"withdrawal net asset value must not be negative"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -20).value();
adj.interestUnrealized = (DROPS_PER_XRP * 15).value();
adj.lossUnrealized = (DROPS_PER_XRP * 20).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Interest unrealized with no outstanding shares.
doInvariantCheck(
{"interest unrealized with no outstanding shares"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 5).value();
}));
// Zero out shares
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
if (!sleShares)
return false;
(*sleShares)[sfOutstandingAmount] = 0;
ac.view().update(sleShares);
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Interest unrealized must not change on non-loan transactions.
doInvariantCheck(
{"vault transaction must not change interest unrealized"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 5).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
testcase << "Vault create";
doInvariantCheck(
{
@@ -3805,6 +3933,128 @@ class Invariants_test : public beast::unit_test::suite
precloseMpt);
}
void
testVaultComputeMinScale()
{
using namespace jtx;
Account const issuer{"issuer"};
PrettyAsset const vaultAsset = issuer["IOU"];
struct TestCase
{
std::string name;
std::int32_t expectedMinScale;
std::vector<ValidVault::DeltaInfo> values;
};
NumberMantissaScaleGuard g{MantissaRange::large};
auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo {
return {n, scale(n, vaultAsset.raw())};
};
auto const testCases = std::vector<TestCase>{
{
.name = "No values",
.expectedMinScale = 0,
.values = {},
},
{
.name = "Mixed integer and Number values",
.expectedMinScale = -15,
.values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})},
},
{
.name = "Mixed scales",
.expectedMinScale = -17,
.values =
{makeDelta(Number{1, -2}), makeDelta(Number{5, -3}), makeDelta(Number{3, -2})},
},
{
.name = "Equal scales",
.expectedMinScale = -16,
.values =
{makeDelta(Number{1, -1}), makeDelta(Number{5, -1}), makeDelta(Number{1, -1})},
},
{
.name = "Mixed mantissa sizes",
.expectedMinScale = -12,
.values =
{makeDelta(Number{1}),
makeDelta(Number{1234, -3}),
makeDelta(Number{12345, -6}),
makeDelta(Number{123, 1})},
},
};
for (auto const& tc : testCases)
{
testcase("vault computeMinScale: " + tc.name);
auto const actualScale = ValidVault::computeMinScale(vaultAsset, tc.values);
BEAST_EXPECTS(
actualScale == tc.expectedMinScale,
"expected: " + std::to_string(tc.expectedMinScale) +
", actual: " + std::to_string(actualScale));
for (auto const& num : tc.values)
{
// None of these scales are far enough apart that rounding the
// values would lose information, so check that the rounded
// value matches the original.
auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale);
BEAST_EXPECTS(
actualRounded == num.delta,
"number " + to_string(num.delta) + " rounded to scale " +
std::to_string(actualScale) + " is " + to_string(actualRounded));
}
}
auto const testCases2 = std::vector<TestCase>{
{
.name = "False equivalence",
.expectedMinScale = -15,
.values =
{
makeDelta(Number{1234567890123456789, -18}),
makeDelta(Number{12345, -4}),
makeDelta(Number{1}),
},
},
};
// Unlike the first set of test cases, the values in these test could
// look equivalent if using the wrong scale.
for (auto const& tc : testCases2)
{
testcase("vault computeMinScale: " + tc.name);
auto const actualScale = ValidVault::computeMinScale(vaultAsset, tc.values);
BEAST_EXPECTS(
actualScale == tc.expectedMinScale,
"expected: " + std::to_string(tc.expectedMinScale) +
", actual: " + std::to_string(actualScale));
std::optional<Number> first;
Number firstRounded;
for (auto const& num : tc.values)
{
if (!first)
{
first = num.delta;
firstRounded = roundToAsset(vaultAsset, num.delta, actualScale);
continue;
}
auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale);
BEAST_EXPECTS(
numRounded != firstRounded,
"at a scale of " + std::to_string(actualScale) + " " + to_string(num.delta) +
" == " + to_string(*first));
}
}
}
public:
void
run() override
@@ -3830,6 +4080,7 @@ public:
testValidPseudoAccounts();
testValidLoanBroker();
testVault();
testVaultComputeMinScale();
}
};

View File

@@ -3,7 +3,6 @@
#include <test/jtx.h>
#include <test/jtx/Account.h>
#include <test/jtx/amount.h>
#include <test/jtx/mpt.h>
#include <xrpl/beast/xor_shift_engine.h>
#include <xrpl/protocol/SField.h>

View File

@@ -1770,10 +1770,21 @@ class LoanBroker_test : public beast::unit_test::suite
testRIPD4274MPT();
}
void
testFeatureLendingProtocolV1_1enabled()
{
using namespace jtx;
testcase("featureLendingProtocolV1_1 enabled");
Env env{*this};
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
}
public:
void
run() override
{
testFeatureLendingProtocolV1_1enabled();
testLoanBrokerSetDebtMaximum();
testLoanBrokerCoverDepositNullVault();

View File

@@ -2665,7 +2665,7 @@ protected:
env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION));
});
#if LOANTODO
#if LOAN_TODO
// TODO
/*
@@ -5328,7 +5328,7 @@ protected:
}
}
#if LOANTODO
#if LOAN_TODO
void
testLoanPayLateFullPaymentBypassesPenalties()
{
@@ -6983,14 +6983,145 @@ protected:
BEAST_EXPECT(afterSecondCoverAvailable == 0);
}
// Tests that vault withdrawals work correctly when the vault has unrealized
// loss from an impaired loan, ensuring the invariant check properly
// accounts for the loss.
void
testWithdrawReflectsUnrealizedLoss()
{
using namespace jtx;
using namespace loan;
using namespace std::chrono_literals;
testcase("Vault withdraw reflects sfLossUnrealized");
// Test constants
static constexpr std::int64_t INITIAL_FUNDING = 1'000'000;
static constexpr std::int64_t LENDER_INITIAL_IOU = 5'000'000;
static constexpr std::int64_t DEPOSITOR_INITIAL_IOU = 1'000'000;
static constexpr std::int64_t BORROWER_INITIAL_IOU = 100'000;
static constexpr std::int64_t DEPOSIT_AMOUNT = 5'000;
static constexpr std::int64_t PRINCIPAL_AMOUNT = 99;
static constexpr std::uint64_t EXPECTED_SHARES_PER_DEPOSITOR = 5'000'000'000;
static constexpr std::uint32_t PAYMENT_INTERVAL = 600;
static constexpr std::uint32_t PAYMENT_TOTAL = 2;
Env env(*this, all);
// Setup accounts
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const depositorA{"lpA"};
Account const depositorB{"lpB"};
Account const borrower{"borrowerA"};
env.fund(XRP(INITIAL_FUNDING), issuer, lender, depositorA, depositorB, borrower);
env.close();
// Setup trust lines
PrettyAsset const iouAsset = issuer[iouCurrency];
env(trust(lender, iouAsset(10'000'000)));
env(trust(depositorA, iouAsset(10'000'000)));
env(trust(depositorB, iouAsset(10'000'000)));
env(trust(borrower, iouAsset(10'000'000)));
env.close();
// Fund accounts with IOUs
env(pay(issuer, lender, iouAsset(LENDER_INITIAL_IOU)));
env(pay(issuer, depositorA, iouAsset(DEPOSITOR_INITIAL_IOU)));
env(pay(issuer, depositorB, iouAsset(DEPOSITOR_INITIAL_IOU)));
env(pay(issuer, borrower, iouAsset(BORROWER_INITIAL_IOU)));
env.close();
// Create vault and broker, then add deposits from two depositors
auto const broker = createVaultAndBroker(env, iouAsset, lender);
Vault v{env};
env(v.deposit({
.depositor = depositorA,
.id = broker.vaultKeylet().key,
.amount = iouAsset(DEPOSIT_AMOUNT),
}),
ter(tesSUCCESS));
env(v.deposit({
.depositor = depositorB,
.id = broker.vaultKeylet().key,
.amount = iouAsset(DEPOSIT_AMOUNT),
}),
ter(tesSUCCESS));
env.close();
// Create a loan
auto const sleBroker = env.le(keylet::loanbroker(broker.brokerID));
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
env(set(borrower, broker.brokerID, PRINCIPAL_AMOUNT),
sig(sfCounterpartySignature, lender),
paymentTotal(PAYMENT_TOTAL),
paymentInterval(PAYMENT_INTERVAL),
fee(env.current()->fees().base * 2),
ter(tesSUCCESS));
env.close();
// Impair the loan to create unrealized loss
env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tesSUCCESS));
env.close();
// Verify unrealized loss is recorded in the vault
auto const vaultAfterImpair = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(vaultAfterImpair))
return;
BEAST_EXPECT(
vaultAfterImpair->at(sfLossUnrealized) == broker.asset(PRINCIPAL_AMOUNT).value());
// Helper to get share balance for a depositor
auto const shareAsset = vaultAfterImpair->at(sfShareMPTID);
auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t {
auto const token = env.le(keylet::mptoken(shareAsset, depositor.id()));
return token ? token->getFieldU64(sfMPTAmount) : 0;
};
// Verify both depositors have equal shares
auto const sharesLpA = getShareBalance(depositorA);
auto const sharesLpB = getShareBalance(depositorB);
BEAST_EXPECT(sharesLpA == EXPECTED_SHARES_PER_DEPOSITOR);
BEAST_EXPECT(sharesLpB == EXPECTED_SHARES_PER_DEPOSITOR);
BEAST_EXPECT(sharesLpA == sharesLpB);
// Helper to attempt withdrawal
auto const attemptWithdrawShares = [&](Account const& depositor,
std::uint64_t shareAmount,
TER expected) {
STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)};
env(v.withdraw(
{.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}),
ter(expected));
env.close();
};
// Regression test: Both depositors should successfully withdraw despite
// unrealized loss. Previously failed with invariant violation:
// "withdrawal must change vault and destination balance by equal
// amount". This was caused by sharesToAssetsWithdraw rounding down,
// creating a mismatch where vaultDeltaAssets * -1 != destinationDelta
// when unrealized loss exists.
attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS);
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
public:
void
run() override
{
#if LOANTODO
#if LOAN_TODO
testLoanPayLateFullPaymentBypassesPenalties();
testLoanCoverMinimumRoundingExploit();
#endif
testWithdrawReflectsUnrealizedLoss();
testInvalidLoanSet();
testCoverDepositWithdrawNonTransferableMPT();

View File

@@ -0,0 +1,900 @@
#include <test/jtx.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STIssue.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <string>
#include <vector>
namespace xrpl {
namespace test {
class VaultHelpers_test : public beast::unit_test::suite
{
jtx::Env* env_ = nullptr;
AccountID const issuerID{0x2};
std::uint32_t const seq{1};
MPTID const shareMPTID = makeMptID(seq, issuerID);
Issue const assetIssue{Currency(0x1), AccountID(0x2)};
MPTIssue const shareIssue{shareMPTID};
SLE::pointer
makeVault(
Number const& assetsTotal,
Number const& lossUnrealized,
Number const& interestUnrealized,
std::int32_t scale)
{
auto sle = std::make_shared<SLE>(keylet::vault(AccountID(0x1), 1));
sle->setFieldIssue(sfAsset, STIssue{sfAsset, assetIssue});
sle->at(sfShareMPTID) = shareMPTID;
sle->at(sfAssetsTotal) = assetsTotal;
sle->at(sfLossUnrealized) = lossUnrealized;
sle->at(sfInterestUnrealized) = interestUnrealized;
sle->at(sfScale) = scale;
return sle;
}
SLE::pointer
makeIssuance(std::uint64_t outstandingAmount)
{
auto sle = std::make_shared<SLE>(keylet::mptIssuance(shareMPTID));
sle->setFieldU64(sfOutstandingAmount, outstandingAmount);
return sle;
}
Rules
rules() const
{
return env_->current()->rules();
}
STAmount
asset(Number const& value) const
{
return STAmount{assetIssue, value};
}
STAmount
shares(Number const& value) const
{
return STAmount{shareIssue, value};
}
void
testAssetsToSharesDeposit()
{
using namespace vault;
struct TestCase
{
std::string name;
Number assetsTotal;
Number interestUnrealized;
std::uint64_t shareTotal;
std::int32_t scale;
Number depositAssets;
STAmount expectedShares;
};
auto const testCases = std::vector<TestCase>{
// Initial: shares = Number(assets.mantissa, assets.exponent + scale).truncate()
{
.name = "Initial deposit IOU scale=6",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 6,
.depositAssets = 100,
.expectedShares =
shares(Number(Number{100}.mantissa(), Number{100}.exponent() + 6).truncate()),
},
{
.name = "Initial deposit scale=0",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 0,
.depositAssets = 10'000'000,
.expectedShares =
shares(Number(Number{10'000'000}.mantissa(), Number{10'000'000}.exponent() + 0)
.truncate()),
},
{
.name = "Initial deposit scale=2",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 2,
.depositAssets = 50,
.expectedShares =
shares(Number(Number{50}.mantissa(), Number{50}.exponent() + 2).truncate()),
},
{
.name = "Initial deposit scale=18",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 18,
.depositAssets = 1,
.expectedShares =
shares(Number(Number{1}.mantissa(), Number{1}.exponent() + 18).truncate()),
},
// Subsequent: shares = floor(shareTotal * assets / depositNAV)
{
.name = "Subsequent proportional",
.assetsTotal = 100,
.interestUnrealized = 0,
.shareTotal = 100'000'000,
.scale = 6,
.depositAssets = 50,
.expectedShares = shares(Number{(100'000'000LL * 50) / 100}.truncate()),
},
{
.name = "With unrealized interest",
.assetsTotal = 1000,
.interestUnrealized = 50,
.shareTotal = 950,
.scale = 0,
.depositAssets = 95,
// depositNAV = 1000 - 50 = 950
.expectedShares = shares(Number{(950 * 95) / 950}.truncate()),
},
{
.name = "Deposit with interest+loss (deposit ignores loss)",
.assetsTotal = 1000,
.interestUnrealized = 50,
.shareTotal = 950,
.scale = 0,
.depositAssets = 1,
// depositNAV = 1000 - 50 = 950
.expectedShares = shares(Number{(950 * 1) / 950}.truncate()),
},
{
.name = "Floor rounds down (inflated vault)",
.assetsTotal = 7,
.interestUnrealized = 0,
.shareTotal = 3,
.scale = 0,
.depositAssets = 3,
// shares = floor(3 * 3 / 7) = floor(9/7) = 1
.expectedShares = shares(Number{(3 * 3) / 7}.truncate()),
},
{
.name = "Tiny into large vault",
.assetsTotal = Number{1, 9},
.interestUnrealized = 0,
.shareTotal = UINT64_C(1'000'000'000'000'000),
.scale = 6,
.depositAssets = Number{1, -6},
.expectedShares =
shares(((Number{1, 15} * Number{1, -6}) / Number{1, 9}).truncate()),
},
{
.name = "Extreme 1e15:1 ratio",
.assetsTotal = Number{1, 15},
.interestUnrealized = 0,
.shareTotal = UINT64_C(1'000'000'000'000'000),
.scale = 0,
.depositAssets = 1,
.expectedShares = shares(((Number{1, 15} * 1) / Number{1, 15}).truncate()),
},
{
.name = "Inflated vault — fewer shares per asset",
.assetsTotal = 110,
.interestUnrealized = 0,
.shareTotal = 100,
.scale = 0,
.depositAssets = 100,
.expectedShares = shares(Number{(100 * 100) / 110}.truncate()),
},
};
for (auto const& tc : testCases)
{
testcase("assetsToSharesDeposit v2: " + tc.name);
auto const vaultSle = makeVault(tc.assetsTotal, 0, tc.interestUnrealized, tc.scale);
auto const issuanceSle = makeIssuance(tc.shareTotal);
auto const result = vault::detail::assetsToSharesDeposit(
vaultSle, issuanceSle, asset(tc.depositAssets));
BEAST_EXPECTS(
result == tc.expectedShares,
"expected " + to_string(tc.expectedShares) + ", got " + to_string(result));
}
}
void
testSharesToAssetsDeposit()
{
using namespace vault;
struct TestCase
{
std::string name;
Number assetsTotal;
Number interestUnrealized;
std::uint64_t shareTotal;
std::int32_t scale;
Number depositShares;
STAmount expectedAssets;
};
auto const testCases = std::vector<TestCase>{
// Initial: assets = Number(shares.mantissa, shares.exponent - scale)
{
.name = "Initial (assetTotal=0) scale=6",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 6,
.depositShares = Number{1, 8},
.expectedAssets =
asset(Number(Number{1, 8}.mantissa(), Number{1, 8}.exponent() - 6)),
},
{
.name = "Initial (assetTotal=0) scale=0",
.assetsTotal = 0,
.interestUnrealized = 0,
.shareTotal = 0,
.scale = 0,
.depositShares = 500,
.expectedAssets = asset(Number(Number{500}.mantissa(), Number{500}.exponent() - 0)),
},
// Subsequent: assets = depositNAV * shares / shareTotal
{
.name = "Proportional back-calc",
.assetsTotal = 100,
.interestUnrealized = 0,
.shareTotal = 100'000'000,
.scale = 6,
.depositShares = Number{5, 7},
.expectedAssets = asset((Number{100} * Number{5, 7}) / 100'000'000),
},
{
.name = "With interest",
.assetsTotal = 1000,
.interestUnrealized = 50,
.shareTotal = 950,
.scale = 0,
.depositShares = 95,
// depositNAV = 950
.expectedAssets = asset(Number{950 * 95} / 950),
},
{
.name = "Non-integer back-calc (7/3)",
.assetsTotal = 7,
.interestUnrealized = 0,
.shareTotal = 3,
.scale = 0,
.depositShares = 1,
// assets = 7 * 1 / 3
.expectedAssets = asset(Number{7} / 3),
},
{
.name = "Floor invariant: actualAssets <= requested",
.assetsTotal = 10,
.interestUnrealized = 0,
.shareTotal = 7,
.scale = 0,
.depositShares = 2,
// assets = 10 * 2 / 7
.expectedAssets = asset(Number{10 * 2} / 7),
},
};
for (auto const& tc : testCases)
{
testcase("sharesToAssetsDeposit v2: " + tc.name);
auto const vaultSle = makeVault(tc.assetsTotal, 0, tc.interestUnrealized, tc.scale);
auto const issuanceSle = makeIssuance(tc.shareTotal);
auto const result = vault::detail::sharesToAssetsDeposit(
vaultSle, issuanceSle, shares(tc.depositShares));
BEAST_EXPECTS(
result == tc.expectedAssets,
"expected " + to_string(tc.expectedAssets) + ", got " + to_string(result));
}
}
void
testAssetsToSharesWithdraw()
{
using namespace vault;
struct TestCase
{
std::string name;
Number assetsTotal;
Number interestUnrealized;
Number lossUnrealized;
std::uint64_t shareTotal;
Number withdrawAssets;
TruncateShares truncate;
STAmount expectedShares;
};
// shares = shareTotal * assets / withdrawalNAV
// withdrawalNAV = assetsTotal - interestUnrealized - lossUnrealized
auto const testCases = std::vector<TestCase>{
{
.name = "Basic no loss",
.assetsTotal = 100,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 100'000'000,
.withdrawAssets = 50,
.truncate = TruncateShares::no,
// NAV = 100
.expectedShares = shares(Number{100'000'000LL * 50} / 100),
},
{
.name = "With paper loss",
.assetsTotal = 10,
.interestUnrealized = 0,
.lossUnrealized = 3,
.shareTotal = 10,
.withdrawAssets = 1,
.truncate = TruncateShares::yes,
// NAV = 7; shares = floor(10 * 1 / 7)
.expectedShares = shares(Number{(10 * 1) / 7}.truncate()),
},
{
.name = "Fractional still floors same",
.assetsTotal = 10,
.interestUnrealized = 0,
.lossUnrealized = 3,
.shareTotal = 10,
.withdrawAssets = Number{11, -1},
.truncate = TruncateShares::yes,
// NAV = 7; shares = floor(10 * 1.1 / 7)
.expectedShares = shares(((Number{10} * Number{11, -1}) / 7).truncate()),
},
{
.name = "Floor at 0.5 boundary (confirms floor, not round)",
.assetsTotal = 10,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 10,
.withdrawAssets = Number{15, -1},
.truncate = TruncateShares::yes,
// NAV = 10; shares = floor(10 * 1.5 / 10)
.expectedShares = shares(((Number{10} * Number{15, -1}) / 10).truncate()),
},
{
.name = "Zero NAV returns zero shares",
.assetsTotal = 100,
.interestUnrealized = 50,
.lossUnrealized = 50,
.shareTotal = 500,
.withdrawAssets = 10,
.truncate = TruncateShares::no,
.expectedShares = shares(0),
},
{
.name = "With loss — assetsOut <= requested",
.assetsTotal = 10,
.interestUnrealized = 0,
.lossUnrealized = 2,
.shareTotal = 10,
.withdrawAssets = 3,
.truncate = TruncateShares::yes,
// NAV = 8; shares = floor(10 * 3 / 8)
.expectedShares = shares(Number{(10 * 3) / 8}.truncate()),
},
{
.name = "With losses and interest (no truncation)",
.assetsTotal = 1000,
.interestUnrealized = 100,
.lossUnrealized = 100,
.shareTotal = 500,
.withdrawAssets = 100,
.truncate = TruncateShares::no,
// NAV = 800; raw = 62.5, assigned to MPT STAmount
.expectedShares = shares(Number{500 * 100} / 800),
},
{
.name = "With losses and interest (truncated)",
.assetsTotal = 1000,
.interestUnrealized = 100,
.lossUnrealized = 100,
.shareTotal = 500,
.withdrawAssets = 100,
.truncate = TruncateShares::yes,
// NAV = 800; shares = floor(500 * 100 / 800)
.expectedShares = shares(Number{(500 * 100) / 800}.truncate()),
},
};
for (auto const& tc : testCases)
{
testcase("assetsToSharesWithdraw v2: " + tc.name);
auto const vaultSle =
makeVault(tc.assetsTotal, tc.lossUnrealized, tc.interestUnrealized, 0);
auto const issuanceSle = makeIssuance(tc.shareTotal);
auto const result = vault::detail::assetsToSharesWithdraw(
vaultSle, issuanceSle, asset(tc.withdrawAssets), tc.truncate);
BEAST_EXPECTS(
result == tc.expectedShares,
"expected " + to_string(tc.expectedShares) + ", got " + to_string(result));
}
}
void
testSharesToAssetsWithdraw()
{
using namespace vault;
struct TestCase
{
std::string name;
Number assetsTotal;
Number interestUnrealized;
Number lossUnrealized;
std::uint64_t shareTotal;
Number withdrawShares;
STAmount expectedAssets;
};
// assets = withdrawalNAV * shares / shareTotal
// withdrawalNAV = assetsTotal - interestUnrealized - lossUnrealized
auto const testCases = std::vector<TestCase>{
{
.name = "Basic redeem half",
.assetsTotal = 100,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 100'000'000,
.withdrawShares = Number{5, 7},
.expectedAssets = asset((Number{100} * Number{5, 7}) / 100'000'000),
},
{
.name = "Redeem all",
.assetsTotal = 100,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 100'000'000,
.withdrawShares = Number{1, 8},
.expectedAssets = asset((Number{100} * Number{1, 8}) / 100'000'000),
},
{
.name = "With loss",
.assetsTotal = 1050,
.interestUnrealized = 50,
.lossUnrealized = 100,
.shareTotal = 1000,
.withdrawShares = 100,
// NAV = 900
.expectedAssets = asset(Number{900 * 100} / 1000),
},
{
.name = "Both interest+loss (spec example)",
.assetsTotal = 1001,
.interestUnrealized = 50,
.lossUnrealized = 100,
.shareTotal = 951,
.withdrawShares = 1,
// NAV = 851
.expectedAssets = asset(Number{851} / 951),
},
{
.name = "Loss only (no interest)",
.assetsTotal = 1100,
.interestUnrealized = 0,
.lossUnrealized = 200,
.shareTotal = 1100,
.withdrawShares = 100,
// NAV = 900
.expectedAssets = asset(Number{900 * 100} / 1100),
},
{
.name = "After hard default",
.assetsTotal = 600,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 1000,
.withdrawShares = 500,
.expectedAssets = asset(Number{600 * 500} / 1000),
},
{
.name = "Non-terminating fraction (1/3)",
.assetsTotal = 1,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 3,
.withdrawShares = 1,
.expectedAssets = asset(Number{1} / 3),
},
{
.name = "Zero NAV returns zero assets",
.assetsTotal = 100,
.interestUnrealized = 50,
.lossUnrealized = 50,
.shareTotal = 500,
.withdrawShares = 10,
.expectedAssets = asset(0),
},
{
.name = "High precision with loss",
.assetsTotal = Number{1, 12},
.interestUnrealized = 0,
.lossUnrealized = Number{2, 11},
.shareTotal = UINT64_C(1'000'000'000'000),
.withdrawShares = 1,
// NAV = 8e11
.expectedAssets = asset(Number{8, 11} / Number{1, 12}),
},
{
.name = "Inflated vault redeem — captures yield",
.assetsTotal = 209,
.interestUnrealized = 0,
.lossUnrealized = 0,
.shareTotal = 190,
.withdrawShares = 100,
.expectedAssets = asset(Number{209 * 100} / 190),
},
};
for (auto const& tc : testCases)
{
testcase("sharesToAssetsWithdraw v2: " + tc.name);
auto const vaultSle =
makeVault(tc.assetsTotal, tc.lossUnrealized, tc.interestUnrealized, 0);
auto const issuanceSle = makeIssuance(tc.shareTotal);
auto const result = vault::detail::sharesToAssetsWithdraw(
vaultSle, issuanceSle, shares(tc.withdrawShares));
BEAST_EXPECTS(
result == tc.expectedAssets,
"expected " + to_string(tc.expectedAssets) + ", got " + to_string(result));
}
}
void
testComputeDeposit()
{
using namespace vault;
testcase("computeDeposit: normal");
{
auto const vaultSle = makeVault(100, 0, 0, 6);
auto const issuanceSle = makeIssuance(100'000'000);
auto const result =
computeDeposit(rules(), vaultSle, issuanceSle, asset(50), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
BEAST_EXPECT(result->shares != beast::zero);
BEAST_EXPECT(result->assets <= asset(50));
}
}
testcase("computeDeposit: initial deposit");
{
auto const vaultSle = makeVault(0, 0, 0, 6);
auto const issuanceSle = makeIssuance(0);
auto const result =
computeDeposit(rules(), vaultSle, issuanceSle, asset(100), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
BEAST_EXPECT(result->shares != beast::zero);
BEAST_EXPECT(result->assets == asset(100));
}
}
testcase("computeDeposit: precision loss returns tecPRECISION_LOSS");
{
// Huge vault, tiny deposit — shares truncate to zero.
auto const vaultSle = makeVault(Number{1, 15}, 0, 0, 0);
auto const issuanceSle = makeIssuance(1);
auto const result = computeDeposit(
rules(), vaultSle, issuanceSle, asset(Number{1, -15}), env_->journal);
BEAST_EXPECT(!result.has_value());
if (!result)
BEAST_EXPECT(result.error() == tecPRECISION_LOSS);
}
}
void
testComputeWithdrawByAssets()
{
using namespace vault;
testcase("computeWithdrawByAssets: normal");
{
auto const vaultSle = makeVault(100, 0, 0, 0);
auto const issuanceSle = makeIssuance(100);
auto const result =
computeWithdrawByAssets(rules(), vaultSle, issuanceSle, asset(50), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
BEAST_EXPECT(result->shares != beast::zero);
BEAST_EXPECT(result->assets != beast::zero);
}
}
testcase("computeWithdrawByAssets: precision loss");
{
auto const vaultSle = makeVault(Number{1, 15}, 0, 0, 0);
auto const issuanceSle = makeIssuance(1);
auto const result = computeWithdrawByAssets(
rules(), vaultSle, issuanceSle, asset(Number{1, -15}), env_->journal);
BEAST_EXPECT(!result.has_value());
if (!result)
BEAST_EXPECT(result.error() == tecPRECISION_LOSS);
}
}
void
testComputeWithdrawByShares()
{
using namespace vault;
testcase("computeWithdrawByShares: normal");
{
auto const vaultSle = makeVault(100, 0, 0, 0);
auto const issuanceSle = makeIssuance(100);
auto const result =
computeWithdrawByShares(rules(), vaultSle, issuanceSle, shares(50), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
BEAST_EXPECT(result->assets != beast::zero);
BEAST_EXPECT(result->shares == shares(50));
}
}
}
void
testComputeClawback()
{
using namespace vault;
testcase("computeClawback: normal");
{
auto const vaultSle = makeVault(1000, 0, 0, 0);
auto const issuanceSle = makeIssuance(1000);
auto const result = computeClawback(
rules(), vaultSle, issuanceSle, asset(100), Number{1000}, env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
BEAST_EXPECT(result->assets != beast::zero);
BEAST_EXPECT(result->shares != beast::zero);
}
}
testcase("computeClawback: clamped to assetsAvailable");
{
// assetsAvailable is small — clawback should be clamped.
auto const vaultSle = makeVault(1000, 0, 0, 0);
auto const issuanceSle = makeIssuance(1000);
auto const result = computeClawback(
rules(), vaultSle, issuanceSle, asset(500), Number{10}, env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
BEAST_EXPECT(result->assets <= asset(10));
}
}
// Creates a Sandbox with vault and issuance SLEs inserted.
Sandbox
makeSandbox(SLE::pointer vault, SLE::pointer issuance)
{
Sandbox sb(env_->current().get(), tapNONE);
sb.insert(vault);
sb.insert(issuance);
return sb;
}
void
testBorrowFromVault()
{
using namespace vault;
testcase("borrowFromVault: normal");
{
auto vaultSle = makeVault(1000, 0, 0, 0);
vaultSle->at(sfAssetsAvailable) = 1000;
vaultSle->at(sfAssetsMaximum) = 0;
auto issuanceSle = makeIssuance(1000);
auto sb = makeSandbox(vaultSle, issuanceSle);
auto const ter = borrowFromVault(sb, vaultSle, 100, 10, env_->journal);
BEAST_EXPECT(ter == tesSUCCESS);
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == 900);
BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == 1010);
BEAST_EXPECT(vaultSle->at(sfInterestUnrealized) == 10);
}
testcase("borrowFromVault: zero yield");
{
auto vaultSle = makeVault(1000, 0, 0, 0);
vaultSle->at(sfAssetsAvailable) = 1000;
vaultSle->at(sfAssetsMaximum) = 0;
auto issuanceSle = makeIssuance(1000);
auto sb = makeSandbox(vaultSle, issuanceSle);
auto const ter = borrowFromVault(sb, vaultSle, 100, 0, env_->journal);
BEAST_EXPECT(ter == tesSUCCESS);
BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == 900);
BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == 1000);
BEAST_EXPECT(vaultSle->at(sfInterestUnrealized) == 0);
}
testcase("borrowFromVault: invalid amount");
{
auto vaultSle = makeVault(1000, 0, 0, 0);
vaultSle->at(sfAssetsAvailable) = 1000;
vaultSle->at(sfAssetsMaximum) = 0;
auto issuanceSle = makeIssuance(1000);
auto sb = makeSandbox(vaultSle, issuanceSle);
BEAST_EXPECT(borrowFromVault(sb, vaultSle, 0, 10, env_->journal) == tecINTERNAL);
BEAST_EXPECT(borrowFromVault(sb, vaultSle, -1, 10, env_->journal) == tecINTERNAL);
}
testcase("borrowFromVault: negative yield");
{
auto vaultSle = makeVault(1000, 0, 0, 0);
vaultSle->at(sfAssetsAvailable) = 1000;
vaultSle->at(sfAssetsMaximum) = 0;
auto issuanceSle = makeIssuance(1000);
auto sb = makeSandbox(vaultSle, issuanceSle);
BEAST_EXPECT(borrowFromVault(sb, vaultSle, 100, -1, env_->journal) == tecINTERNAL);
}
testcase("borrowFromVault: insufficient available");
{
auto vaultSle = makeVault(1000, 0, 0, 0);
vaultSle->at(sfAssetsAvailable) = 50;
vaultSle->at(sfAssetsMaximum) = 0;
auto issuanceSle = makeIssuance(1000);
auto sb = makeSandbox(vaultSle, issuanceSle);
BEAST_EXPECT(borrowFromVault(sb, vaultSle, 100, 10, env_->journal) == tecINTERNAL);
}
}
void
testV1Routing()
{
using namespace vault;
// Use non-zero interestUnrealized to distinguish v1 from v2.
// v1 ignores interestUnrealized in NAV, v2 subtracts it.
// With assetsTotal=100, interestUnrealized=20, shares=100, scale=0:
// v1 deposit NAV = 100 (raw assetsTotal)
// v2 deposit NAV = 80 (assetsTotal - interestUnrealized)
// v1 withdraw NAV = 100 (assetsTotal - lossUnrealized)
// v2 withdraw NAV = 80 (assetsTotal - interestUnrealized - lossUnrealized)
testcase("v1 routing: computeDeposit");
{
auto const vaultSle = makeVault(100, 0, 20, 0);
auto const issuanceSle = makeIssuance(100);
auto const result =
computeDeposit(rules(), vaultSle, issuanceSle, asset(40), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
// v1: 100 * 40 / 100 = 40 shares (v2 would give 50)
BEAST_EXPECT(result->shares == shares(40));
}
}
testcase("v1 routing: computeWithdrawByShares");
{
auto const vaultSle = makeVault(100, 0, 20, 0);
auto const issuanceSle = makeIssuance(100);
auto const result =
computeWithdrawByShares(rules(), vaultSle, issuanceSle, shares(50), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
// v1: 100 * 50 / 100 = 50 assets (v2 would give 40)
BEAST_EXPECT(result->assets == asset(50));
}
}
testcase("v1 routing: computeWithdrawByAssets");
{
auto const vaultSle = makeVault(100, 0, 20, 0);
auto const issuanceSle = makeIssuance(100);
auto const result =
computeWithdrawByAssets(rules(), vaultSle, issuanceSle, asset(50), env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
// v1: 100 * 50 / 100 = 50 shares (v2 would give 62.5 → 62)
BEAST_EXPECT(result->shares == shares(50));
}
}
testcase("v1 routing: computeClawback");
{
auto const vaultSle = makeVault(100, 0, 20, 0);
auto const issuanceSle = makeIssuance(100);
auto const result = computeClawback(
rules(), vaultSle, issuanceSle, asset(50), Number{100}, env_->journal);
BEAST_EXPECT(result.has_value());
if (result)
{
// v1 uses same withdraw NAV (100), so shares = 50
BEAST_EXPECT(result->shares == shares(50));
BEAST_EXPECT(result->assets == asset(50));
}
}
}
public:
void
run() override
{
using namespace jtx;
Env env{*this};
env_ = &env;
// v2 tests (amendment enabled by default)
testAssetsToSharesDeposit();
testSharesToAssetsDeposit();
testAssetsToSharesWithdraw();
testSharesToAssetsWithdraw();
// High-level API tests
testComputeDeposit();
testComputeWithdrawByAssets();
testComputeWithdrawByShares();
testComputeClawback();
testBorrowFromVault();
// v1 routing tests (amendment disabled)
{
Env v1Env{*this};
v1Env.disableFeature(featureLendingProtocolV1_1);
env_ = &v1Env;
testV1Routing();
env_ = &env;
}
}
};
BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
} // namespace test
} // namespace xrpl

View File

@@ -1081,6 +1081,7 @@ class Vault_test : public beast::unit_test::suite
Asset const& asset,
Vault& vault)> test) {
Env env{*this, testable_amendments() | featureSingleAssetVault};
Account issuer{"issuer"};
Account owner{"owner"};
Account depositor{"depositor"};
@@ -5231,6 +5232,63 @@ class Vault_test : public beast::unit_test::suite
}
}
void
testVaultDeleteData()
{
using namespace test::jtx;
Env env{*this};
Account const owner{"owner"};
env.fund(XRP(1'000'000), owner);
env.close();
Vault vault{env};
auto const keylet = keylet::vault(owner.id(), 1);
auto delTx = vault.del({.owner = owner, .id = keylet.key});
// Test VaultDelete with featureLendingProtocolV1_1 disabled
// Transaction fails if the data field is provided
{
testcase("VaultDelete data featureLendingProtocolV1_1 disabled");
env.disableFeature(featureLendingProtocolV1_1);
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
env(delTx, ter(temDISABLED));
env.close();
env.enableFeature(featureLendingProtocolV1_1);
}
// Transaction fails if the data field is too large
{
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data too large");
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength + 1, 'A'));
env(delTx, ter(temMALFORMED));
env.close();
}
// Transaction fails if the data field is set, but is empty
{
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data empty");
delTx[sfMemoData] = strHex(std::string(0, 'A'));
env(delTx, ter(temMALFORMED));
env.close();
}
{
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data valid");
PrettyAsset const xrpAsset = xrpIssue();
auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
env(tx, ter(tesSUCCESS));
env.close();
// Recreate the transaction as the vault keylet changed
auto delTx = vault.del({.owner = owner, .id = keylet.key});
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
env(delTx, ter(tesSUCCESS));
env.close();
}
}
public:
void
run() override
@@ -5251,6 +5309,7 @@ public:
testVaultClawbackBurnShares();
testVaultClawbackAssets();
testAssetsMaximum();
testVaultDeleteData();
}
};

View File

@@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
auto const interestUnrealizedValue = canonical_NUMBER();
VaultBuilder builder{
previousTxnIDValue,
@@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip)
builder.setAssetsMaximum(assetsMaximumValue);
builder.setLossUnrealized(lossUnrealizedValue);
builder.setScale(scaleValue);
builder.setInterestUnrealized(interestUnrealizedValue);
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
@@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasScale());
}
{
auto const& expected = interestUnrealizedValue;
auto const actualOpt = entry.getInterestUnrealized();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfInterestUnrealized");
EXPECT_TRUE(entry.hasInterestUnrealized());
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
@@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
auto const shareMPTIDValue = canonical_UINT192();
auto const withdrawalPolicyValue = canonical_UINT8();
auto const scaleValue = canonical_UINT8();
auto const interestUnrealizedValue = canonical_NUMBER();
auto sle = std::make_shared<SLE>(Vault::entryType, index);
@@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
sle->at(sfShareMPTID) = shareMPTIDValue;
sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue;
sle->at(sfScale) = scaleValue;
sle->at(sfInterestUnrealized) = interestUnrealizedValue;
VaultBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
@@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfScale");
}
{
auto const& expected = interestUnrealizedValue;
auto const fromSleOpt = entryFromSle.getInterestUnrealized();
auto const fromBuilderOpt = entryFromBuilder.getInterestUnrealized();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfInterestUnrealized");
expectEqualField(expected, *fromBuilderOpt, "sfInterestUnrealized");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
@@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getLossUnrealized().has_value());
EXPECT_FALSE(entry.hasScale());
EXPECT_FALSE(entry.getScale().has_value());
EXPECT_FALSE(entry.hasInterestUnrealized());
EXPECT_FALSE(entry.getInterestUnrealized().has_value());
}
}

View File

@@ -30,6 +30,7 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
// Transaction-specific field values
auto const vaultIDValue = canonical_UINT256();
auto const memoDataValue = canonical_VL();
VaultDeleteBuilder builder{
accountValue,
@@ -39,6 +40,7 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
};
// Set optional fields
builder.setMemoData(memoDataValue);
auto tx = builder.build(publicKey, secretKey);
@@ -62,6 +64,14 @@ TEST(TransactionsVaultDeleteTests, BuilderSettersRoundTrip)
}
// Verify optional fields
{
auto const& expected = memoDataValue;
auto const actualOpt = tx.getMemoData();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMemoData should be present";
expectEqualField(expected, *actualOpt, "sfMemoData");
EXPECT_TRUE(tx.hasMemoData());
}
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -79,6 +89,7 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
// Transaction-specific field values
auto const vaultIDValue = canonical_UINT256();
auto const memoDataValue = canonical_VL();
// Build an initial transaction
VaultDeleteBuilder initialBuilder{
@@ -88,6 +99,7 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
feeValue
};
initialBuilder.setMemoData(memoDataValue);
auto initialTx = initialBuilder.build(publicKey, secretKey);
@@ -112,6 +124,13 @@ TEST(TransactionsVaultDeleteTests, BuilderFromStTxRoundTrip)
}
// Verify optional fields
{
auto const& expected = memoDataValue;
auto const actualOpt = rebuiltTx.getMemoData();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMemoData should be present";
expectEqualField(expected, *actualOpt, "sfMemoData");
}
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -142,5 +161,35 @@ TEST(TransactionsVaultDeleteTests, BuilderThrowsOnWrongTxType)
EXPECT_THROW(VaultDeleteBuilder{wrongTx.getSTTx()}, std::runtime_error);
}
// 5) Build with only required fields and verify optional fields return nullopt.
TEST(TransactionsVaultDeleteTests, OptionalFieldsReturnNullopt)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::secp256k1, generateSeed("testVaultDeleteNullopt"));
// Common transaction fields
auto const accountValue = calcAccountID(publicKey);
std::uint32_t const sequenceValue = 3;
auto const feeValue = canonical_AMOUNT();
// Transaction-specific required field values
auto const vaultIDValue = canonical_UINT256();
VaultDeleteBuilder builder{
accountValue,
vaultIDValue,
sequenceValue,
feeValue
};
// Do NOT set optional fields
auto tx = builder.build(publicKey, secretKey);
// Verify optional fields are not present
EXPECT_FALSE(tx.hasMemoData());
EXPECT_FALSE(tx.getMemoData().has_value());
}
}