Skip to content

feat: change share valuation logic#6564

Open
Tapanito wants to merge 14 commits into
tapanito/lending-fix-amendmentfrom
tapanito/vault-share-pricing
Open

feat: change share valuation logic#6564
Tapanito wants to merge 14 commits into
tapanito/lending-fix-amendmentfrom
tapanito/vault-share-pricing

Conversation

@Tapanito

@Tapanito Tapanito commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

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 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
@Tapanito Tapanito marked this pull request as ready for review March 24, 2026 11:33
@Tapanito Tapanito force-pushed the tapanito/vault-share-pricing branch from 98c963a to 2e2c88a Compare March 24, 2026 12:53
@Tapanito Tapanito force-pushed the tapanito/vault-share-pricing branch from 2e2c88a to d9487a0 Compare March 24, 2026 13:14
@codecov

codecov Bot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.74324% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.4%. Comparing base (68e4fbd) to head (2dc2d4f).
⚠️ Report is 205 commits behind head on tapanito/lending-fix-amendment.

Files with missing lines Patch % Lines
src/libxrpl/ledger/helpers/VaultHelpers.cpp 76.9% 55 Missing ⚠️
src/libxrpl/tx/transactors/vault/VaultClawback.cpp 88.9% 1 Missing ⚠️
src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp 88.9% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                       Coverage Diff                        @@
##           tapanito/lending-fix-amendment   #6564     +/-   ##
================================================================
- Coverage                            81.5%   81.4%   -0.0%     
================================================================
  Files                                 999     999             
  Lines                               74550   74759    +209     
  Branches                             7560    7612     +52     
================================================================
+ Hits                                60722   60866    +144     
- Misses                              13828   13893     +65     
Files with missing lines Coverage Δ
include/xrpl/protocol/detail/ledger_entries.macro 100.0% <ø> (ø)
...clude/xrpl/protocol_autogen/ledger_entries/Vault.h 100.0% <100.0%> (ø)
src/libxrpl/ledger/View.cpp 96.1% <ø> (ø)
src/libxrpl/tx/invariants/VaultInvariant.cpp 97.5% <100.0%> (+0.1%) ⬆️
src/libxrpl/tx/transactors/vault/VaultDeposit.cpp 98.0% <100.0%> (-0.2%) ⬇️
src/libxrpl/tx/transactors/vault/VaultClawback.cpp 96.8% <88.9%> (-0.9%) ⬇️
src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp 97.7% <88.9%> (-1.3%) ⬇️
src/libxrpl/ledger/helpers/VaultHelpers.cpp 77.9% <76.9%> (-22.1%) ⬇️

... and 3 files with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Tapanito

Copy link
Copy Markdown
Collaborator Author

Hey @ximinez ,

This discussion: #6381 (comment) made me think..

We're changing share valuation logic by introducing sfInterestUnrealized field, and doing a bunch of "stuff" with it. The question is, how do we handle existing Vaults that were created before introducing this field?

I'd say, these objects should still use the old share valuation logic, even if the amendment was enabled. However, perhaps they shouldn't be allowed to create new loans?

@github-actions

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.

Two issues flagged inline: missing sfAssetsAvailable in the test helper weakens validation coverage, and borrowFromVault mutates the SLE before validating, leaving it in an inconsistent state on error.

Review by Claude Opus 4.6 · Prompt: V12

sle->at(sfLossUnrealized) = lossUnrealized;
sle->at(sfInterestUnrealized) = interestUnrealized;
sle->at(sfScale) = scale;
return sle;

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.

makeVault omits sfAssetsAvailable, so validateVaultState sees it as 0, masking invariant checks. Add it before returning:

        sle->at(sfAssetsAvailable) = assetsTotal;
        return sle;

{
JLOG(j.error()) << "borrowFromVault: invalid input"
<< " (amount=" << amount << ", yield=" << yield << ")";
return tecINTERNAL;

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.

SLE fields are mutated before validateVaultState — on error the in-memory SLE is dirty and inconsistent. Validate the expected post-state values before applying mutations, or snapshot and restore fields on failure.

@ximinez

ximinez commented May 12, 2026

Copy link
Copy Markdown
Collaborator

This discussion: #6381 (comment) made me think..

We're changing share valuation logic by introducing sfInterestUnrealized field, and doing a bunch of "stuff" with it. The question is, how do we handle existing Vaults that were created before introducing this field?

I'd say, these objects should still use the old share valuation logic, even if the amendment was enabled. However, perhaps they shouldn't be allowed to create new loans?

Whatever solution you use, you'll need to change sfInterestUnrealized from soeDefault to soeOptional. This will allow us to distinguish an existing vault from one that just doesn't have any interest-bearing loans outstanding.

Some ideas:

  1. If sfInterestUnrealized is unset, then compute it the first time it's needed, which could be VaultDeposit, VaultWithdraw, LoanSet, LoanPay, and maybe others. In other words, lazy fill. You'd have to walk the Vault pseudo-account's directory to find all the loan brokers, then all the loan brokers' directories to find the loans, but the computation from there is easy. constructRoundedLoanState will do the work for you.
    • The risk is that the lookups are potentially unbounded, but really, how many total objects can we be looking at here? The Vault PsAccount directory might have a trustline or MPToken to the vault asset issuer, will have the MPTIssuance (but no MPTokens for the issuance), and the loan brokers. The Loan Broker PsAccount will be similar: a trustline to the vault asset issuer, and the loans. Right?
  2. Do like you said, use the old logic, disallow creating new loans, but with a twist: Whenever the last loan for a broker is deleted, walk the Vault PsAccount's directory. If there are no other loan brokers with loans sfOwnerCount, set sfInterestUnrealized to 0. This allows the Vault to start using the new logic. It's more bound, because you can exit the check at the first broker with loans.
    • Or even safer, do the check when deleting the broker. If there are any other brokers, break out, but if it's the last broker, set sfInterestUnrealized to 0. That's more bounded, because there won't more than a handful of other objects.
  3. Do like you said, use the old logic, disallow creating new loans, and implement a LedgerStateFix that anybody can run to do the summation from idea one, but at a higher cost. All holders in the Vault will be motivated to run it, and it only has to be run once. And you can still set an upper bound on the total number of objects encountered, where you abort but still charge the fee. Once you have a value, you can start issuing loans again.

@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;
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants