mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-26 03:57:07 +00:00
Compare commits
54 Commits
mvadari/te
...
tapanito/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cc24bcf1e | ||
|
|
461a7aee66 | ||
|
|
88cc3dd15a | ||
|
|
2a9d394b69 | ||
|
|
2fa8305e95 | ||
|
|
3dd44ae18a | ||
|
|
9eaebf2eea | ||
|
|
664b9ba5fe | ||
|
|
189f2d60bd | ||
|
|
beb8a1872d | ||
|
|
24db40e56c | ||
|
|
da4513d096 | ||
|
|
2e2fddefe9 | ||
|
|
db997ecad9 | ||
|
|
2dc2d4f8f7 | ||
|
|
68e4fbdf2b | ||
|
|
bb0a09ae21 | ||
|
|
d94232007f | ||
|
|
d9487a02bb | ||
|
|
df8bfbe5af | ||
|
|
347d1a19ef | ||
|
|
a5ce36ea42 | ||
|
|
8f7a813ac1 | ||
|
|
c0fc689113 | ||
|
|
d65fab27a1 | ||
|
|
a8914da275 | ||
|
|
59148bb7a5 | ||
|
|
bf2dbe11bf | ||
|
|
b5d25c5ab1 | ||
|
|
7dcbb7b215 | ||
|
|
52afb76fd8 | ||
|
|
57d31c3842 | ||
|
|
b45068b9dd | ||
|
|
7222150095 | ||
|
|
a67da5c2ed | ||
|
|
d2f23b2f5b | ||
|
|
4067e5025f | ||
|
|
ed4330a7d6 | ||
|
|
feba605998 | ||
|
|
b322097529 | ||
|
|
e159d27373 | ||
|
|
ba53026006 | ||
|
|
34773080df | ||
|
|
3c3bd75991 | ||
|
|
7459fe454d | ||
|
|
106bf48725 | ||
|
|
74c968d4e3 | ||
|
|
167147281c | ||
|
|
ba60306610 | ||
|
|
6674500896 | ||
|
|
c5d7ebe93d | ||
|
|
d0b5ca9dab | ||
|
|
5e51893e9b | ||
|
|
3422c11d02 |
@@ -1,66 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Rules.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
#include <optional>
|
||||
#include <expected>
|
||||
|
||||
namespace xrpl {
|
||||
namespace xrpl::vault {
|
||||
|
||||
/** From the perspective of a vault, return the number of shares to give
|
||||
depositor when they offer a fixed amount of assets. Note, since shares are
|
||||
MPT, this number is integral and always truncated in this calculation.
|
||||
|
||||
@param vault The vault SLE.
|
||||
@param issuance The MPTokenIssuance SLE for the vault's shares.
|
||||
@param assets The amount of assets to convert.
|
||||
|
||||
@return The number of shares, or nullopt on error.
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& assets);
|
||||
|
||||
/** From the perspective of a vault, return the number of assets to take from
|
||||
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(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares);
|
||||
|
||||
/** Controls whether to truncate shares instead of rounding. */
|
||||
enum class TruncateShares : bool { No = false, Yes = true };
|
||||
|
||||
/** Controls whether the withdraw conversion helpers
|
||||
(assetsToSharesWithdraw and sharesToAssetsWithdraw) subtract
|
||||
sfLossUnrealized from sfAssetsTotal before computing the exchange rate.
|
||||
The default (No) applies the standard discounted rate; Yes is used when
|
||||
the redeemer is the sole remaining shareholder.
|
||||
*/
|
||||
enum class WaiveUnrealizedLoss : 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.
|
||||
@param waive Whether to waive the unrealized-loss discount when computing
|
||||
the exchange rate.
|
||||
[[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(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
@@ -68,35 +34,143 @@ assetsToSharesWithdraw(
|
||||
TruncateShares truncate = TruncateShares::No,
|
||||
WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::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.
|
||||
|
||||
@param vault The vault SLE.
|
||||
@param issuance The MPTokenIssuance SLE for the vault's shares.
|
||||
@param shares The amount of shares to convert.
|
||||
@param waive Whether to waive (i.e. not subtract) the vault's unrealized
|
||||
loss when computing the exchange rate.
|
||||
|
||||
@return The number of assets, or nullopt on error.
|
||||
*/
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
[[nodiscard]] STAmount
|
||||
sharesToAssetsWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No);
|
||||
|
||||
/** Returns true iff `account` holds all of the vault's outstanding shares —
|
||||
i.e. is the sole remaining shareholder. Returns false if the account
|
||||
holds no shares or fewer than the total outstanding.
|
||||
} // namespace detail
|
||||
|
||||
// High-level API — orchestrates forward+reverse conversions, handles overflow.
|
||||
|
||||
@param view The ledger view.
|
||||
@param account The candidate sole shareholder.
|
||||
@param issuance The MPTokenIssuance SLE for the vault's shares; provides
|
||||
both the share MPTID and the outstanding-amount total.
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance);
|
||||
|
||||
} // namespace xrpl
|
||||
[[nodiscard]] STAmount
|
||||
sharesToAssetsWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No);
|
||||
|
||||
/** 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]] std::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]] std::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]] std::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]] std::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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// Add new amendments to the top of this list.
|
||||
// Keep it sorted in reverse chronological order.
|
||||
|
||||
XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (Cleanup3_3_0, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo)
|
||||
|
||||
@@ -491,6 +491,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)
|
||||
}))
|
||||
|
||||
@@ -225,6 +225,7 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
|
||||
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
|
||||
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfInterestUnrealized, NUMBER, 18, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
|
||||
// int32
|
||||
TYPED_SFIELD(sfLoanScale, INT32, 1)
|
||||
|
||||
@@ -887,6 +887,7 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete,
|
||||
MustDeleteAcct | DestroyMptIssuance | MustModifyVault,
|
||||
({
|
||||
{sfVaultID, SoeRequired},
|
||||
{sfMemoData, SoeOptional},
|
||||
}))
|
||||
|
||||
/** This transaction trades assets for shares with a vault. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -50,6 +50,7 @@ class ValidVault
|
||||
Number assetsAvailable = 0;
|
||||
Number assetsMaximum = 0;
|
||||
Number lossUnrealized = 0;
|
||||
Number interestUnrealized = 0;
|
||||
|
||||
Vault static make(SLE const&);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
|
||||
//
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
@@ -12,58 +14,60 @@
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
namespace xrpl {
|
||||
namespace xrpl::vault {
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
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() + vault->at(sfScale)).truncate()};
|
||||
}
|
||||
Number(assets.mantissa(), assets.exponent() + scale).truncate(),
|
||||
};
|
||||
|
||||
Number const interestUnrealized = vault->at(sfInterestUnrealized);
|
||||
Number const shareTotal = issuance->at(sfOutstandingAmount);
|
||||
shares = ((shareTotal * assets) / assetTotal).truncate();
|
||||
auto const netAssetValue = assetTotal - interestUnrealized;
|
||||
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::assetsToSharesDeposit : positive NAV");
|
||||
|
||||
shares = ((shareTotal * assets) / netAssetValue).truncate();
|
||||
return shares;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
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);
|
||||
auto const scale = vault->at(sfScale);
|
||||
STAmount assets{vault->at(sfAsset)};
|
||||
if (assetTotal == 0)
|
||||
{
|
||||
return STAmount{
|
||||
assets.asset(), shares.mantissa(), shares.exponent() - vault->at(sfScale), false};
|
||||
}
|
||||
assets.asset(),
|
||||
shares.mantissa(),
|
||||
shares.exponent() - scale,
|
||||
false,
|
||||
};
|
||||
|
||||
Number const interestUnrealized = vault->at(sfInterestUnrealized);
|
||||
Number const shareTotal = issuance->at(sfOutstandingAmount);
|
||||
assets = (assetTotal * shares) / shareTotal;
|
||||
auto const netAssetValue = assetTotal - interestUnrealized;
|
||||
XRPL_ASSERT(netAssetValue > 0, "xrpl::vault::detail::sharesToAssetsDeposit : positive NAV");
|
||||
|
||||
assets = (netAssetValue * shares) / shareTotal;
|
||||
return assets;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
STAmount
|
||||
assetsToSharesWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
@@ -71,13 +75,91 @@ assetsToSharesWithdraw(
|
||||
TruncateShares truncate,
|
||||
WaiveUnrealizedLoss waive)
|
||||
{
|
||||
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 const assetTotal = vault->at(sfAssetsTotal);
|
||||
Number const interestUnrealized = vault->at(sfInterestUnrealized);
|
||||
Number const lossUnrealized =
|
||||
waive == WaiveUnrealizedLoss::No ? vault->at(sfLossUnrealized) : Number{0};
|
||||
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,
|
||||
WaiveUnrealizedLoss waive)
|
||||
{
|
||||
Number const assetTotal = vault->at(sfAssetsTotal);
|
||||
Number const interestUnrealized = vault->at(sfInterestUnrealized);
|
||||
Number const lossUnrealized =
|
||||
waive == WaiveUnrealizedLoss::No ? vault->at(sfLossUnrealized) : Number{0};
|
||||
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;
|
||||
}
|
||||
|
||||
STAmount
|
||||
sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
STAmount
|
||||
assetsToSharesWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& assets,
|
||||
TruncateShares truncate,
|
||||
WaiveUnrealizedLoss waive)
|
||||
{
|
||||
Number assetTotal = vault->at(sfAssetsTotal);
|
||||
if (waive == WaiveUnrealizedLoss::No)
|
||||
assetTotal -= vault->at(sfLossUnrealized);
|
||||
@@ -92,20 +174,13 @@ assetsToSharesWithdraw(
|
||||
return shares;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<STAmount>
|
||||
STAmount
|
||||
sharesToAssetsWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
WaiveUnrealizedLoss waive)
|
||||
{
|
||||
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);
|
||||
if (waive == WaiveUnrealizedLoss::No)
|
||||
assetTotal -= vault->at(sfLossUnrealized);
|
||||
@@ -117,12 +192,399 @@ sharesToAssetsWithdraw(
|
||||
return assets;
|
||||
}
|
||||
|
||||
} // 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, WaiveUnrealizedLoss::No);
|
||||
}
|
||||
|
||||
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, WaiveUnrealizedLoss::No);
|
||||
return v1::sharesToAssetsWithdraw(vault, issuance, shares, WaiveUnrealizedLoss::No);
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
[[nodiscard]] std::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 std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (assets.asset() != vault->at(sfAsset))
|
||||
{
|
||||
JLOG(j.error()) << "computeDeposit: asset mismatch";
|
||||
return std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (rules.enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (auto const ter = validateVaultState(vault, issuance, j))
|
||||
return std::unexpected(ter);
|
||||
}
|
||||
try
|
||||
{
|
||||
auto const shares = assetsToSharesDeposit(rules, vault, issuance, assets);
|
||||
if (shares == beast::kZero)
|
||||
return std::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 std::unexpected(tecINTERNAL);
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
return ExchangeResult{.assets = assetsOut, .shares = 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 std::unexpected(tecPATH_DRY);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::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 std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (assets.asset() != vault->at(sfAsset))
|
||||
{
|
||||
JLOG(j.error()) << "computeWithdrawByAssets: asset mismatch";
|
||||
return std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (rules.enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (auto const ter = validateVaultState(vault, issuance, j))
|
||||
return std::unexpected(ter);
|
||||
}
|
||||
try
|
||||
{
|
||||
auto const shares = assetsToSharesWithdraw(rules, vault, issuance, assets);
|
||||
if (shares == beast::kZero)
|
||||
return std::unexpected(tecPRECISION_LOSS);
|
||||
|
||||
auto const assetsOut = sharesToAssetsWithdraw(rules, vault, issuance, shares);
|
||||
return ExchangeResult{.assets = assetsOut, .shares = 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 std::unexpected(tecPATH_DRY);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::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 std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (shares.asset() != vault->at(sfShareMPTID))
|
||||
{
|
||||
JLOG(j.error()) << "computeWithdrawByShares: share asset mismatch";
|
||||
return std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (rules.enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (auto const ter = validateVaultState(vault, issuance, j))
|
||||
return std::unexpected(ter);
|
||||
}
|
||||
try
|
||||
{
|
||||
auto const assets = sharesToAssetsWithdraw(rules, vault, issuance, shares);
|
||||
return ExchangeResult{.assets = assets, .shares = 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 std::unexpected(tecPATH_DRY);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::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 std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (clawbackAmount.asset() != vault->at(sfAsset))
|
||||
{
|
||||
JLOG(j.error()) << "computeClawback: asset mismatch";
|
||||
return std::unexpected(tecINTERNAL);
|
||||
}
|
||||
if (rules.enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (auto const ter = validateVaultState(vault, issuance, j))
|
||||
return std::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 std::unexpected(tecINTERNAL);
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
}
|
||||
|
||||
return ExchangeResult{.assets = assetsRecovered, .shares = 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 std::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;
|
||||
}
|
||||
[[nodiscard]] bool
|
||||
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
|
||||
"xrpl::isSoleShareholder : valid issuance SLE");
|
||||
"xrpl::vault::isSoleShareholder : valid issuance SLE");
|
||||
|
||||
std::uint64_t const outstanding = issuance->at(sfOutstandingAmount);
|
||||
if (outstanding == 0)
|
||||
@@ -137,4 +599,14 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
|
||||
return sleToken->getFieldU64(sfMPTAmount) == outstanding;
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
[[nodiscard]] STAmount
|
||||
sharesToAssetsWithdraw(
|
||||
SLE::const_ref vault,
|
||||
SLE::const_ref issuance,
|
||||
STAmount const& shares,
|
||||
WaiveUnrealizedLoss waive)
|
||||
{
|
||||
return detail::sharesToAssetsWithdraw(vault, issuance, shares, waive);
|
||||
}
|
||||
|
||||
} // namespace xrpl::vault
|
||||
|
||||
@@ -44,6 +44,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;
|
||||
}
|
||||
|
||||
@@ -492,6 +493,48 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
if (view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (afterVault.interestUnrealized < kZero)
|
||||
{
|
||||
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 > kZero &&
|
||||
afterVault.assetsTotal - afterVault.interestUnrealized <= kZero)
|
||||
{
|
||||
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 <
|
||||
kZero)
|
||||
{
|
||||
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 > kZero &&
|
||||
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)
|
||||
@@ -506,8 +549,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,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();
|
||||
|
||||
// Pre-fixCleanup3_1_3: zero-amount clawback returned early without
|
||||
// clamping to assetsAvailable, allowing more assets to be recovered
|
||||
@@ -246,86 +247,19 @@ VaultClawback::assetsToClawback(
|
||||
{
|
||||
auto const sharesDestroyed = accountHolds(
|
||||
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
auto const result =
|
||||
vault::computeWithdrawByShares(rules, vault, sleShareIssuance, sharesDestroyed, j_);
|
||||
if (!result)
|
||||
return std::unexpected(result.error());
|
||||
|
||||
return std::make_pair(*maybeAssets, sharesDestroyed);
|
||||
return std::make_pair(result->assets, result->shares);
|
||||
}
|
||||
|
||||
STAmount sharesDestroyed;
|
||||
STAmount assetsRecovered;
|
||||
|
||||
try
|
||||
{
|
||||
if (clawbackAmount == beast::kZero)
|
||||
{
|
||||
sharesDestroyed = accountHolds(
|
||||
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
|
||||
assetsRecovered = *maybeAssets;
|
||||
}
|
||||
else
|
||||
{
|
||||
auto const maybeShares =
|
||||
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
|
||||
if (!maybeShares)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
sharesDestroyed = *maybeShares;
|
||||
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
if (!maybeAssets)
|
||||
return std::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 std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
sharesDestroyed = *maybeShares;
|
||||
}
|
||||
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
assetsRecovered = *maybeAssets;
|
||||
if (assetsRecovered > *assetsAvailable)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.error()) << "VaultClawback: invalid rounding of shares.";
|
||||
return std::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 std::unexpected(tecPATH_DRY);
|
||||
}
|
||||
|
||||
return std::make_pair(assetsRecovered, sharesDestroyed);
|
||||
auto const result = vault::computeClawback(
|
||||
rules, vault, sleShareIssuance, clawbackAmount, Number{assetsAvailable}, j_);
|
||||
if (!result)
|
||||
return std::unexpected(result.error());
|
||||
return std::make_pair(result->assets, result->shares);
|
||||
}
|
||||
|
||||
TER
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/MPTIssue.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
@@ -28,6 +30,14 @@ 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, kMaxDataPayloadLength))
|
||||
return temMALFORMED;
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
#include <xrpl/tx/transactors/vault/VaultDeposit.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
//
|
||||
#include <xrpl/ledger/helpers/CredentialHelpers.h>
|
||||
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
@@ -20,10 +16,8 @@
|
||||
#include <xrpl/protocol/STTakesAsset.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/tx/transactors/token/MPTokenAuthorize.h>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
@@ -255,44 +249,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::kZero)
|
||||
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(),
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
//
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
@@ -20,23 +15,21 @@
|
||||
#include <xrpl/protocol/STTakesAsset.h>
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
static WaiveUnrealizedLoss
|
||||
static vault::WaiveUnrealizedLoss
|
||||
shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
issuance && issuance->getType() == ltMPTOKEN_ISSUANCE,
|
||||
"xrpl::shouldWaiveWithdrawal : valid issuance sle");
|
||||
|
||||
return view.rules().enabled(fixCleanup3_2_0) && isSoleShareholder(view, account, issuance)
|
||||
? WaiveUnrealizedLoss::Yes
|
||||
: WaiveUnrealizedLoss::No;
|
||||
return view.rules().enabled(fixCleanup3_2_0) &&
|
||||
vault::isSoleShareholder(view, account, issuance)
|
||||
? vault::WaiveUnrealizedLoss::Yes
|
||||
: vault::WaiveUnrealizedLoss::No;
|
||||
}
|
||||
|
||||
NotTEC
|
||||
@@ -121,16 +114,14 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(ctx.view, account, sleIssuance);
|
||||
try
|
||||
{
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return tefINTERNAL; // LCOV_EXCL_LINE
|
||||
auto const assetsEquiv =
|
||||
vault::sharesToAssetsWithdraw(vault, sleIssuance, amount, waiveUnrealizedLoss);
|
||||
|
||||
if (auto const ret = canWithdraw(
|
||||
ctx.view,
|
||||
account,
|
||||
dstAcct,
|
||||
*maybeAssets,
|
||||
assetsEquiv,
|
||||
ctx.tx.isFieldPresent(sfDestinationTag)))
|
||||
return ret;
|
||||
}
|
||||
@@ -194,65 +185,26 @@ VaultWithdraw::doApply()
|
||||
// to deposit into it, and this means you are also indefinitely authorized
|
||||
// to withdraw from it.
|
||||
|
||||
STAmount assetsWithdrawn;
|
||||
STAmount sharesRedeemed;
|
||||
|
||||
auto const amount = ctx_.tx[sfAmount];
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
|
||||
MPTIssue const share{mptIssuanceID};
|
||||
STAmount sharesRedeemed = {share};
|
||||
STAmount assetsWithdrawn;
|
||||
auto const& rules = ctx_.view().rules();
|
||||
|
||||
// When the user is the sole shareholder they own both the available and future value.
|
||||
// We waive the unrealized-loss subtraction in this case to avoid user withdrawing all of their
|
||||
// shares but keeping future value in the vault.
|
||||
auto const waiveUnrealizedLoss = shouldWaiveWithdrawal(view(), accountID_, sleIssuance);
|
||||
try
|
||||
{
|
||||
auto const result = [&]() -> std::expected<vault::ExchangeResult, TER> {
|
||||
if (amount.asset() == vaultAsset)
|
||||
{
|
||||
// Fixed assets, variable shares.
|
||||
{
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
|
||||
if (!maybeShares)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
sharesRedeemed = *maybeShares;
|
||||
}
|
||||
|
||||
if (sharesRedeemed == beast::kZero)
|
||||
return tecPRECISION_LOSS;
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleIssuance, sharesRedeemed, waiveUnrealizedLoss);
|
||||
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, waiveUnrealizedLoss);
|
||||
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;
|
||||
}
|
||||
return vault::computeWithdrawByAssets(rules, vault, sleIssuance, amount, j_);
|
||||
if (amount.asset() == share)
|
||||
return vault::computeWithdrawByShares(rules, vault, sleIssuance, amount, j_);
|
||||
return std::unexpected(tefINTERNAL); // LCOV_EXCL_LINE
|
||||
}();
|
||||
if (!result)
|
||||
return result.error();
|
||||
assetsWithdrawn = result.value().assets;
|
||||
sharesRedeemed = result.value().shares;
|
||||
|
||||
if (accountHolds(
|
||||
view(), accountID_, share, FreezeHandling::ZeroIfFrozen, AuthHandling::IgnoreAuth, j_) <
|
||||
|
||||
@@ -2670,6 +2670,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)
|
||||
@@ -2684,11 +2685,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)
|
||||
@@ -3349,6 +3352,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());
|
||||
kAdjust(ac.view(), keylet, kArgs(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());
|
||||
kAdjust(ac.view(), keylet, kArgs(A2.id(), 10, [](Adjustments& adj) {
|
||||
adj.assetsAvailable = (kDropsPerXrp * -10).value();
|
||||
adj.interestUnrealized = (kDropsPerXrp * 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());
|
||||
kAdjust(ac.view(), keylet, kArgs(A2.id(), 10, [](Adjustments& adj) {
|
||||
adj.assetsAvailable = (kDropsPerXrp * -20).value();
|
||||
adj.interestUnrealized = (kDropsPerXrp * 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());
|
||||
kAdjust(ac.view(), keylet, kArgs(A2.id(), 10, [](Adjustments& adj) {
|
||||
adj.assetsAvailable = (kDropsPerXrp * -20).value();
|
||||
adj.interestUnrealized = (kDropsPerXrp * 15).value();
|
||||
adj.lossUnrealized = (kDropsPerXrp * 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());
|
||||
kAdjust(ac.view(), keylet, kArgs(A2.id(), 10, [](Adjustments& adj) {
|
||||
adj.assetsAvailable = (kDropsPerXrp * -10).value();
|
||||
adj.interestUnrealized = (kDropsPerXrp * 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());
|
||||
kAdjust(ac.view(), keylet, kArgs(A2.id(), 10, [](Adjustments& adj) {
|
||||
adj.assetsAvailable = (kDropsPerXrp * -10).value();
|
||||
adj.interestUnrealized = (kDropsPerXrp * 5).value();
|
||||
}));
|
||||
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
|
||||
precloseXrp,
|
||||
TxAccount::A2);
|
||||
|
||||
testcase << "Vault create";
|
||||
doInvariantCheck(
|
||||
{
|
||||
|
||||
900
src/test/app/VaultHelpers_test.cpp
Normal file
900
src/test/app/VaultHelpers_test.cpp
Normal 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::kZero);
|
||||
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::kZero);
|
||||
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::kZero);
|
||||
BEAST_EXPECT(result->assets != beast::kZero);
|
||||
}
|
||||
}
|
||||
|
||||
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::kZero);
|
||||
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::kZero);
|
||||
BEAST_EXPECT(result->shares != beast::kZero);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -7824,6 +7824,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 const 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(kMaxDataPayloadLength, 'A'));
|
||||
env(delTx, Ter(temDISABLED));
|
||||
env.enableFeature(featureLendingProtocolV1_1);
|
||||
env.close();
|
||||
}
|
||||
|
||||
// Transaction fails if the data field is too large
|
||||
{
|
||||
testcase("VaultDelete data featureLendingProtocolV1_1 enabled data too large");
|
||||
delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 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(kMaxDataPayloadLength, 'A'));
|
||||
env(delTx, Ter(tesSUCCESS));
|
||||
env.close();
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -7852,6 +7909,7 @@ public:
|
||||
testVaultClawbackAssets();
|
||||
testVaultEscrowedMPT();
|
||||
testAssetsMaximum();
|
||||
testVaultDeleteData();
|
||||
testBug6LimitBypassWithShares();
|
||||
testRemoveEmptyHoldingLockedAmount();
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user