Skip to content

feat: change share valuation logic - #6564

Closed
Tapanito wants to merge 54 commits into
developfrom
tapanito/vault-share-pricing
Closed

feat: change share valuation logic#6564
Tapanito wants to merge 54 commits into
developfrom
tapanito/vault-share-pricing

Conversation

@Tapanito

@Tapanito Tapanito commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Specification: XRPLF/XRPL-Standards#485

  • Introduce interestUnrealized into vault share pricing math (v2, gated behind featureLendingProtocolV1_1). Deposit NAV uses assetsTotal - interestUnrealized, withdrawal NAV uses assetsTotal - interestUnrealized - lossUnrealized. v1 behaviour is unchanged.
  • Extract share pricing into a high-level compute* API returning Expected<ExchangeResult, TER>, replacing raw std::optional<STAmount> in transactors.
  • Add borrowFromVault helper that updates vault state when a loan is issued, tracking yield in interestUnrealized (v1_1 only) with post-modification state validation.
  • Add 6 invariants for interestUnrealized: non-negative, within lent assets, deposit/withdrawal NAV bounds, shares consistency, and immutability outside loan transactions.
  • Document rounding behaviour: withdraw-by-assets can return slightly more than requested due to banker's rounding on the intermediate share value; the per-share price is preserved.
  • Lending Protocol transactors (LoanSet, LoanPay, LoanManage) will be updated to use borrowFromVault and the new share pricing API in a follow-up PR.

High Level Overview of Change

Context of Change

API Impact

  • Public API: New feature (new methods and/or new fields)
  • Public API: Breaking change (in general, breaking changes should only impact the next api_version)
  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)
  • Peer protocol change (must be backward compatible or bump the peer protocol version)

Tapanito and others added 21 commits February 4, 2026 11:30
* adds sfMemoData field to VaultDelete transaction
Co-authored-by: Ed Hennis <ed@ripple.com>
…#6527)

Use XRPL_FEATURE macro instead of XRPL_FIX since
LendingProtocolV1_1 is a feature amendment, not a fix.
Update all references in VaultDelete and related tests.
@Tapanito
Tapanito requested a review from ximinez March 17, 2026 18:26
Move vault pricing helpers from xrpl namespace into xrpl::vault with
amendment-gated dispatch (fixLendingProtocolV1_1). Extract v2 pure math
into public xrpl::vault::math::v2 for unit testability. v1 logic is
intentionally left as-is to avoid risk since Single Asset Vault is
already released.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Tapanito
Tapanito force-pushed the tapanito/vault-share-pricing branch from ca6a477 to b45068b Compare March 17, 2026 19:08
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

1 similar comment
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

@github-actions

Copy link
Copy Markdown

All conflicts have been resolved. Assigned reviewers can now start or resume their review.

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues.

Review by Claude Sonnet 4.6 · Prompt: V15

}
return vault::computeWithdrawByAssets(rules, vault, sleIssuance, amount, j_);
if (amount.asset() == share)
return vault::computeWithdrawByShares(rules, vault, sleIssuance, amount, j_);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Severity: HIGH

computeWithdrawByShares also hardcodes WaiveUnrealizedLoss::No via the internal dispatch. The sole shareholder withdrawing by share count is equally affected — they receive fewer assets than entitled to, contradicting the fixCleanup3_2_0 waiver logic still enforced in preclaim.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The computeWithdrawByShares function (and the anonymous-namespace sharesToAssetsWithdraw it calls) hardcodes WaiveUnrealizedLoss::No, discarding the waiver logic that preclaim properly computes via shouldWaiveWithdrawal. To fix this:

  1. In VaultHelpers.h and VaultHelpers.cpp: Add a WaiveUnrealizedLoss parameter to computeWithdrawByShares (and the anonymous-namespace sharesToAssetsWithdraw it calls at line ~314-322), so the waiver can be propagated instead of hardcoded to No.

  2. In VaultWithdraw.cpp doApply: Compute waiveUnrealizedLoss using shouldWaiveWithdrawal(ctx_.view(), accountID_, sleIssuance) (same as done in preclaim at line 114), and pass it to the updated computeWithdrawByShares call at line 198.

  3. Similarly for computeWithdrawByAssets: The same hardcoded WaiveUnrealizedLoss::No is present in the assetsToSharesWithdraw anonymous-namespace helper (line ~301-311). Consider propagating the waiver there too for consistency.

This ensures that doApply uses the same waived valuation that preclaim approved, preventing the sole shareholder from receiving fewer assets than entitled (or hitting the UNREACHABLE assertion on full withdrawal with unrealized losses).

}

return std::make_pair(assetsRecovered, sharesDestroyed);
auto const result = vault::computeClawback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Severity: HIGH

When fixCleanup3_1_3 is enabled and clawbackAmount is zero (documented to mean "clawback all"), computeClawback(0) converts 0 assets → 0 shares → returns {0, 0}, causing tecPRECISION_LOSS. The old code explicitly fetched all holder shares for this case. The "clawback all" semantic is broken.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: When fixCleanup3_1_3 is enabled and clawbackAmount is zero ("clawback all" semantic), the code must resolve zero to the holder's actual share balance before computing the exchange, just as the pre-fix path does, but with additional clamping to assetsAvailable.

Add a new branch between the existing pre-fix handler (lines 246–256) and the computeClawback call (line 258) for the post-fix zero-amount case:

  1. Check clawbackAmount == beast::kZero (at this point, fixCleanup3_1_3 must be enabled since the pre-fix path didn't match).
  2. Fetch the holder's shares via accountHolds(view(), holder, share, ...).
  3. Compute assets from shares via vault::computeWithdrawByShares(rules, vault, sleShareIssuance, sharesHeld, j_).
  4. If the computed assets <= assetsAvailable, return the result directly.
  5. Otherwise, clamp by calling vault::computeClawback(rules, vault, sleShareIssuance, STAmount{asset, assetsAvailable}, Number{assetsAvailable}, j_) to properly handle the clamping with share truncation and rounding.

This preserves the pre-fix path for ledger replay compatibility while correctly implementing the "clawback all" semantic under the amendment.

{
if (rules.enabled(featureLendingProtocolV1_1))
return detail::assetsToSharesWithdraw(vault, issuance, assets, truncate);
return v1::assetsToSharesWithdraw(vault, issuance, assets, truncate, WaiveUnrealizedLoss::No);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Severity: MEDIUM

The anonymous-namespace v1 dispatch hardcodes WaiveUnrealizedLoss::No, making it impossible for the high-level compute* API to ever apply the sole-shareholder waiver. This is the root cause of the VaultWithdraw regression — the waiver path is architecturally blocked.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Thread the WaiveUnrealizedLoss parameter through the entire withdrawal computation path so that the sole-shareholder waiver can actually take effect in doApply():

  1. Add a WaiveUnrealizedLoss waive parameter to the anonymous-namespace wrapper functions assetsToSharesWithdraw (line 301) and sharesToAssetsWithdraw (line 313), and forward it instead of hardcoding WaiveUnrealizedLoss::No.

  2. Add the same WaiveUnrealizedLoss waive parameter to the public computeWithdrawByAssets and computeWithdrawByShares APIs (and their declarations in the header), forwarding it to the anonymous-namespace helpers.

  3. In VaultWithdraw::doApply() (VaultWithdraw.cpp ~line 194-198), compute shouldWaiveWithdrawal() and pass the result to vault::computeWithdrawByAssets / vault::computeWithdrawByShares so the waiver is consistently applied in both preclaim() and doApply().

This ensures that when the user is the sole shareholder, the unrealized-loss subtraction is waived during the actual withdrawal computation, matching the existing behaviour in preclaim().

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All clear

Review by Claude Sonnet 4.6 · Prompt: V15

Comment on lines +562 to +565
// Update vault state
if (view.rules().enabled(featureLendingProtocolV1_1))
vault->at(sfInterestUnrealized) += yield;
vault->at(sfAssetsAvailable) -= amount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

borrowFromVault mutates the vault SLE in-place (shared_ptr) before calling validateVaultState. If validation fails at line 575, the error is returned but the SLE is never restored. The caller's reference sees corrupted state (reduced AssetsAvailable, inflated AssetsTotal/InterestUnrealized), which can cause incorrect financial calculations if the caller doesn't immediately discard the SLE.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Save the original vault field values before in-place mutation and restore them if validateVaultState (or the issuance read) fails. This prevents callers from observing corrupted in-memory state on error paths.

Replace the mutation + validation block (lines 562–577) with a pattern that saves originals and uses a rollback lambda on error:

  1. Save sfAssetsAvailable and sfAssetsTotal before mutation.
  2. After mutation, on every error path inside the featureLendingProtocolV1_1 block, roll back sfInterestUnrealized, sfAssetsAvailable, and sfAssetsTotal before returning the error.

A concise approach is a rollback lambda that reverses the three mutations, called on every early-return path.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
// Update vault state
if (view.rules().enabled(featureLendingProtocolV1_1))
vault->at(sfInterestUnrealized) += yield;
vault->at(sfAssetsAvailable) -= amount;
// Update vault state
// Save original values for rollback on validation failure
auto const origAssetsAvailable = vault->at(sfAssetsAvailable);
auto const origAssetsTotal = vault->at(sfAssetsTotal);
if (view.rules().enabled(featureLendingProtocolV1_1))
vault->at(sfInterestUnrealized) += yield;
vault->at(sfAssetsAvailable) -= amount;
vault->at(sfAssetsTotal) += yield;
if (view.rules().enabled(featureLendingProtocolV1_1))
{
auto const rollback = [&]() {
vault->at(sfInterestUnrealized) -= yield;
vault->at(sfAssetsAvailable) = origAssetsAvailable;
vault->at(sfAssetsTotal) = origAssetsTotal;
};
std::shared_ptr<SLE const> issuance =
view.read(keylet::mptIssuance(vault->at(sfShareMPTID)));
if (!issuance)
{
rollback(); // LCOV_EXCL_LINE
return tecINTERNAL; // LCOV_EXCL_LINE
}
if (auto const ter = validateVaultState(vault, issuance, j))
{
rollback();
return ter;
}
}

@Tapanito
Tapanito force-pushed the tapanito/lending-fix-amendment branch from eaf80ce to 60700c5 Compare June 26, 2026 12:48
@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates vault share valuation for Lending Protocol v1.1 by introducing sfInterestUnrealized into NAV calculations (deposit vs. withdrawal), refactoring share-pricing into higher-level compute* APIs, and adding new invariants/tests to enforce the new accounting rules. It also extends VaultDelete to optionally carry a top-level sfMemoData field (amendment-gated) for a deletion reason payload.

Changes:

  • Add sfInterestUnrealized (defaulted NUMBER) to the Vault ledger entry and incorporate it into v1.1 deposit/withdraw NAV math.
  • Introduce new high-level vault exchange APIs returning std::expected<ExchangeResult, TER> and a borrowFromVault state update helper with post-modification validation.
  • Add/extend tests and invariants for interestUnrealized, and add amendment-gated sfMemoData support on VaultDelete.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/tests/libxrpl/protocol_autogen/transactions/VaultDeleteTests.cpp Adds round-trip coverage for new optional sfMemoData on VaultDelete autogen wrapper/builder.
src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp Adds round-trip + nullopt coverage for new default field sfInterestUnrealized on Vault autogen wrapper/builder.
src/test/app/VaultHelpers_test.cpp New unit tests for v2 share-pricing math + high-level compute* APIs + borrowFromVault, plus v1 routing checks.
src/test/app/Vault_test.cpp Adds app-level tests for VaultDelete memo-data validation and feature gating.
src/test/app/Invariants_test.cpp Adds invariant tests for sfInterestUnrealized constraints and immutability rules.
src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp Refactors withdraw pricing to use vault::computeWithdraw* APIs (but currently misses loss-waiver semantics; see comments).
src/libxrpl/tx/transactors/vault/VaultDeposit.cpp Refactors deposit pricing to use vault::computeDeposit.
src/libxrpl/tx/transactors/vault/VaultDelete.cpp Adds amendment-gated top-level sfMemoData validation (length + non-empty).
src/libxrpl/tx/transactors/vault/VaultClawback.cpp Refactors clawback math to vault::computeWithdrawByShares / vault::computeClawback.
src/libxrpl/tx/invariants/VaultInvariant.cpp Adds v1.1 invariant checks for interestUnrealized and restricts when it may change.
include/xrpl/tx/invariants/VaultInvariant.h Extends invariant snapshot struct with interestUnrealized.
src/libxrpl/ledger/helpers/VaultHelpers.cpp Implements v2 NAV logic, validation, compute* APIs, and borrowFromVault; retains v1 math internally.
include/xrpl/ledger/helpers/VaultHelpers.h Declares new vault::detail math + high-level compute* APIs and borrowFromVault.
include/xrpl/protocol/detail/sfields.macro Registers sfInterestUnrealized (NUMBER).
include/xrpl/protocol/detail/ledger_entries.macro Adds sfInterestUnrealized as SoeDefault on ltVAULT.
include/xrpl/protocol/detail/transactions.macro Adds optional top-level sfMemoData to ttVAULT_DELETE.
include/xrpl/protocol/detail/features.macro Introduces featureLendingProtocolV1_1.
include/xrpl/protocol_autogen/transactions/VaultDelete.h Autogen wrapper/builder support for sfMemoData.
include/xrpl/protocol_autogen/ledger_entries/Vault.h Autogen wrapper/builder support for sfInterestUnrealized.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 114 to +118
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);
Comment on lines +197 to +201
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_);
Comment on lines +51 to +56
[[nodiscard]] STAmount
sharesToAssetsWithdraw(
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& shares,
WaiveUnrealizedLoss waive = WaiveUnrealizedLoss::No);
Comment on lines +105 to +109
computeWithdrawByAssets(
Rules const& rules,
SLE::const_ref vault,
SLE::const_ref issuance,
STAmount const& assets,
Comment on lines +12 to +14
#include <expected>

namespace xrpl {
namespace xrpl::vault {
Base automatically changed from tapanito/lending-fix-amendment to develop July 1, 2026 00:25
@Tapanito

Copy link
Copy Markdown
Contributor Author

Closing in favor of: #7817

@Tapanito Tapanito closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants