Skip to content

fix: Add rounding to Vault invariants#6632

Open
Tapanito wants to merge 8 commits intodevelopfrom
tapanito/vault-rounding-fix
Open

fix: Add rounding to Vault invariants#6632
Tapanito wants to merge 8 commits intodevelopfrom
tapanito/vault-rounding-fix

Conversation

@Tapanito
Copy link
Copy Markdown
Collaborator

@Tapanito Tapanito commented Mar 24, 2026

Summary

Cherry-picked from: #6217

  • Vault invariant checks now round balance deltas to a common scale before comparing them, preventing false invariant failures caused by floating-point representation differences between Number and STAmount
  • Introduces a DeltaInfo struct that tracks both the delta value and its scale (exponent), and a computeMinScale helper that finds the coarsest scale across all values being compared
  • Adds a scale() utility function in STAmount.h that returns the STAmount exponent for a given Number/Asset pair
  • Adds unit tests for computeMinScale and an end-to-end Loan test (testLoanCoverWithdrawAfterInterest) that exercises the rounding fix through a multi-step lending scenario

Edit:

The PR contains one more additional commit: Replace std::optional with plain int, using std::numeric_limits::min() as a sentinel to detect uninitialized state. The optional was unnecessary since every code path that stores a DeltaInfo into deltas_ always sets scale.

Context

When vault invariants compare balance changes across different ledger objects (vault pseudo-account, trust lines, MPTs), the values may have been computed via different arithmetic paths, resulting in Number values that are mathematically equal but differ at insignificant digits. Previously, invariant checks compared these values directly, which could cause spurious invariant failures — particularly in lending scenarios where interest accrual introduces small rounding discrepancies.

The fix rounds all compared values to the coarsest (least precise) scale present among the operands before performing equality/inequality checks, ensuring that insignificant digit differences don't trigger false failures.

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)

Co-authored-by: Ed Hennis <ed@ripple.com>
@Tapanito Tapanito requested review from bthomee and ximinez March 24, 2026 17:06
@Tapanito Tapanito changed the title Add rounding to Vault invariants (#6217) fix: Add rounding to Vault invariants Mar 24, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 99.23664% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.4%. Comparing base (5c8dfe5) to head (cf339fc).
⚠️ Report is 15 commits behind head on develop.

Files with missing lines Patch % Lines
src/libxrpl/tx/invariants/VaultInvariant.cpp 99.2% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##           develop   #6632     +/-   ##
=========================================
- Coverage     81.5%   81.4%   -0.0%     
=========================================
  Files          999    1006      +7     
  Lines        74458   74513     +55     
  Branches      7556    7559      +3     
=========================================
+ Hits         60649   60688     +39     
- Misses       13809   13825     +16     
Files with missing lines Coverage Δ
include/xrpl/protocol/STAmount.h 95.7% <100.0%> (+0.1%) ⬆️
...clude/xrpl/tx/transactors/lending/LendingHelpers.h 95.2% <100.0%> (ø)
...tx/transactors/lending/LoanBrokerCoverWithdraw.cpp 96.3% <100.0%> (ø)
src/libxrpl/tx/invariants/VaultInvariant.cpp 98.4% <99.2%> (+0.2%) ⬆️

... and 231 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.

@bthomee bthomee requested review from pratikmankawde and removed request for bthomee March 24, 2026 18:39
@kennyzlei kennyzlei requested a review from a1q123456 March 25, 2026 16:10
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Silent invariant-bypass risk in computeMinScale — see inline.

Review by Claude Opus 4.6 · Prompt: V12

Replace std::optional<int> with plain int, using
std::numeric_limits<int>::min() as a sentinel to detect uninitialized
state. The optional was unnecessary since every code path that stores a
DeltaInfo into deltas_ always sets scale.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Five issues flagged inline: assert-disabled sentinel propagation, zero-balance trust-line scale edge case, rounding tolerance masking financial discrepancies, dead asset parameter in computeMinScale, and potential round-up at the cover-availability security boundary.

Review by Claude Opus 4.6 · Prompt: V12

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Four issues flagged inline: INT_MIN sentinel bypasses production safety (two sites), an unused asset parameter, and a misleading function name.

Review by Claude Opus 4.6 · Prompt: V12

…nused asset parameter

The function returns the maximum (coarsest) exponent, not the minimum
scale. Rename to computeCoarsestScale to accurately reflect behavior.
Also remove the unused Asset parameter from the signature.
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

One high-severity bypass risk flagged inline — the value_or(cMaxOffset) fallback in computeCoarsestScale silently disables invariant checks in release builds.

Review by Claude Opus 4.6 · Prompt: V12

@pratikmankawde
Copy link
Copy Markdown
Contributor

pratikmankawde commented Mar 26, 2026

As I was reading this PR, I kept thinking if we can get away from repeated allocation of STAmount objects with rounded off numbers, by instead using a dynamically computed epsilon and doing the almost_equal(flaot1, float2, epsilon) based comparison. I think it will be much more efficient and simple to understand. With my limited understanding of the STAmount and Number classes, I think it will be accurate as well. The epsilon will be calculated similar to scale. If scale is 10^-14, then that becomes the epsilon.

@ximinez Have you already tried the epsilon based comparison approach?

I ran some scenarios on this with AI and asked it to check if the accuracy holds. This is a short summary of my suggestion:
(I can share detailed analysis later if you want)


When considering using |a - b| <= 10^minScale instead of rounding both sides via roundToAsset, the scale computation (computeMinScale, DeltaInfo) would stay the same — only the comparison step changes.

Performance:

Each roundToAsset call constructs a temporary STAmount (running canonicalize()) and then roundToScale does an add+subtract trick through Number arithmetic. The deposit path alone has ~6 such calls. An epsilon comparison is a single subtraction + absolute value + compare — significantly cheaper per invariant check.

Robustness at rounding boundaries:

The roundToScale trick (value + ref) - ref applies banker's rounding at the target scale. Two values representing the same logical quantity with noise at digits 17-19 can straddle the rounding boundary at digit 16 and round to different STAmount mantissas:

a = 5,050,000,000,000,000,499 × 10⁻¹⁷   //→  rounds down to ...000 × 10⁻¹⁴
b = 5,050,000,000,000,000,501 × 10⁻¹⁷   //→  rounds up   to ...001 × 10⁻¹⁴

roundToAsset:  round(a) ≠ round(b)  //→  false invariant violation
epsilon:       |a - b| = 2×10⁻¹⁷ < 10⁻¹⁴  //→  correctly equal

This is rare (requires digit 16 at a rounding tie + opposite-direction noise), but interest accrual arithmetic could produce it.

One tradeoff — the > comparison: The single magnitude check vaultDeltaAssets > txAmount (deposit must not exceed tx amount) would become a - b > epsilon, which misses violations of exactly 1 ULP. But a 1-ULP discrepancy (e.g., 10⁻¹⁴ out of 50.5) represents ~0.000000000002% of the value — not a meaningful security concern and real bugs produce much larger mismatches.

Summary:

Across all 14 rounded comparisons in finalize (8 equality, 5 sign, 1 magnitude), epsilon is equivalent or more robust for 13, with a negligible theoretical tradeoff on 1. It's also cheaper to compute.

Copy link
Copy Markdown
Contributor

@pratikmankawde pratikmankawde left a comment

Choose a reason for hiding this comment

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

Added a comment about a slightly diff., but performant, approach on implementing this.

};

public:
struct DeltaInfo final
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.

I think it's worth implementing arithmetic operations for this class so that we can simplify the code a lot.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done.

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.

Sorry, I don't see what you did here?

Copy link
Copy Markdown
Contributor

@a1q123456 a1q123456 left a comment

Choose a reason for hiding this comment

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

Left some suggestions. looks good otherwise.

Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Took a pass through this

One high-severity issue: the value_or(STAmount::cMaxOffset) fallback at line 1068 contradicts the assert above it and silently disables invariant checks in release builds — see inline comments.


Review by ReviewBot 🤖

Review by Claude Opus 4.6 · Prompt: V12

@Tapanito
Copy link
Copy Markdown
Collaborator Author

Tapanito commented Apr 2, 2026

@pratikmankawde , while your suggestion might work, it's out of scope. The current implementation is sufficient for the task at hand.

@a1q123456
Copy link
Copy Markdown
Contributor

As I was reading this PR, I kept thinking if we can get away from repeated allocation of STAmount objects with rounded off numbers, by instead using a dynamically computed epsilon and doing the almost_equal(flaot1, float2, epsilon) based comparison. I think it will be much more efficient and simple to understand. With my limited understanding of the STAmount and Number classes, I think it will be accurate as well. The epsilon will be calculated similar to scale. If scale is 10^-14, then that becomes the epsilon.

@ximinez Have you already tried the epsilon based comparison approach?

I ran some scenarios on this with AI and asked it to check if the accuracy holds. This is a short summary of my suggestion: (I can share detailed analysis later if you want)

When considering using |a - b| <= 10^minScale instead of rounding both sides via roundToAsset, the scale computation (computeMinScale, DeltaInfo) would stay the same — only the comparison step changes.

Performance:

Each roundToAsset call constructs a temporary STAmount (running canonicalize()) and then roundToScale does an add+subtract trick through Number arithmetic. The deposit path alone has ~6 such calls. An epsilon comparison is a single subtraction + absolute value + compare — significantly cheaper per invariant check.

Robustness at rounding boundaries:

The roundToScale trick (value + ref) - ref applies banker's rounding at the target scale. Two values representing the same logical quantity with noise at digits 17-19 can straddle the rounding boundary at digit 16 and round to different STAmount mantissas:

a = 5,050,000,000,000,000,499 × 10⁻¹⁷   //→  rounds down to ...000 × 10⁻¹⁴
b = 5,050,000,000,000,000,501 × 10⁻¹⁷   //→  rounds up   to ...001 × 10⁻¹⁴

roundToAsset:  round(a) ≠ round(b)  //→  false invariant violation
epsilon:       |a - b| = 2×10⁻¹⁷ < 10⁻¹⁴  //→  correctly equal

This is rare (requires digit 16 at a rounding tie + opposite-direction noise), but interest accrual arithmetic could produce it.

One tradeoff — the > comparison: The single magnitude check vaultDeltaAssets > txAmount (deposit must not exceed tx amount) would become a - b > epsilon, which misses violations of exactly 1 ULP. But a 1-ULP discrepancy (e.g., 10⁻¹⁴ out of 50.5) represents ~0.000000000002% of the value — not a meaningful security concern and real bugs produce much larger mismatches.

Summary:

Across all 14 rounded comparisons in finalize (8 equality, 5 sign, 1 magnitude), epsilon is equivalent or more robust for 13, with a negligible theoretical tradeoff on 1. It's also cheaper to compute.

epsilon based comparisons may be faster but the errors can accumulate after a couple of arithmetic operations and you'll be in the state where you are not sure if the two numbers are not equal or it was because of accumulated error

Copy link
Copy Markdown
Contributor

@a1q123456 a1q123456 left a comment

Choose a reason for hiding this comment

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

.

@a1q123456 a1q123456 self-requested a review April 2, 2026 11:06

if (*vaultDeltaAssets >= zero)
// Get the most coarse scale to round calculations to
auto const totalDelta = DeltaInfo{
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.

We can probably have something like

DeltaInfo DeltaInfo::diffVault(Vault const& a, Vault const& b)
{
    XRPL_ASSERT(a.asset == b.asset, "Vaults have the same asset");
    return DeltaInfo{
        a.assetTotal - b.assetTotal,
        std::max(
            scale(a.assetTotal, b.asset),
            scale(b.assetTotal, b.asset)
        )
    };
}

@pratikmankawde
Copy link
Copy Markdown
Contributor

As I was reading this PR, I kept thinking if we can get away from repeated allocation of STAmount objects with rounded off numbers, by instead using a dynamically computed epsilon and doing the almost_equal(flaot1, float2, epsilon) based comparison. I think it will be much more efficient and simple to understand. With my limited understanding of the STAmount and Number classes, I think it will be accurate as well. The epsilon will be calculated similar to scale. If scale is 10^-14, then that becomes the epsilon.
@ximinez Have you already tried the epsilon based comparison approach?
I ran some scenarios on this with AI and asked it to check if the accuracy holds. This is a short summary of my suggestion: (I can share detailed analysis later if you want)
When considering using |a - b| <= 10^minScale instead of rounding both sides via roundToAsset, the scale computation (computeMinScale, DeltaInfo) would stay the same — only the comparison step changes.

Performance:

Each roundToAsset call constructs a temporary STAmount (running canonicalize()) and then roundToScale does an add+subtract trick through Number arithmetic. The deposit path alone has ~6 such calls. An epsilon comparison is a single subtraction + absolute value + compare — significantly cheaper per invariant check.

Robustness at rounding boundaries:

The roundToScale trick (value + ref) - ref applies banker's rounding at the target scale. Two values representing the same logical quantity with noise at digits 17-19 can straddle the rounding boundary at digit 16 and round to different STAmount mantissas:

a = 5,050,000,000,000,000,499 × 10⁻¹⁷   //→  rounds down to ...000 × 10⁻¹⁴
b = 5,050,000,000,000,000,501 × 10⁻¹⁷   //→  rounds up   to ...001 × 10⁻¹⁴

roundToAsset:  round(a) ≠ round(b)  //→  false invariant violation
epsilon:       |a - b| = 2×10⁻¹⁷ < 10⁻¹⁴  //→  correctly equal

This is rare (requires digit 16 at a rounding tie + opposite-direction noise), but interest accrual arithmetic could produce it.
One tradeoff — the > comparison: The single magnitude check vaultDeltaAssets > txAmount (deposit must not exceed tx amount) would become a - b > epsilon, which misses violations of exactly 1 ULP. But a 1-ULP discrepancy (e.g., 10⁻¹⁴ out of 50.5) represents ~0.000000000002% of the value — not a meaningful security concern and real bugs produce much larger mismatches.

Summary:

Across all 14 rounded comparisons in finalize (8 equality, 5 sign, 1 magnitude), epsilon is equivalent or more robust for 13, with a negligible theoretical tradeoff on 1. It's also cheaper to compute.

epsilon based comparisons may be faster but the errors can accumulate after a couple of arithmetic operations and you'll be in the state where you are not sure if the two numbers are not equal or it was because of accumulated error

I am not suggesting to use epsilon for operations, I am suggesting it for comparisons. So, we won't be loosing any precision iteratively.

@Tapanito The current implementation might solve the problem at hand, but it also introduces some latency. Also. the current solution is not a simple one(or even easy to understand and maintain) to be replaced later. It will take equal, if not more, efforts to update it. My suggestion is to just think about the alternate solution, see if it works and if it does, then maybe prefer that one. Specially if this is not high priority fix for currently planned release.

return std::nullopt;

return it->second * sign;
return DeltaInfo{it->second.delta * sign, it->second.scale};
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.

This is the only occurrence so it's probably not 100% worth it but I think it looks clearer if we can have:


DeltaInfo operator-(DeltaInfo const& info, std::int8_t sign /* or a bool */)
{
    return Deltainfo{info.delta * sign, info.scale}
}

and then this piece of code will become:

return it->second * sign;

if (*vaultDeltaShares * -1 != *accountDeltaShares)
// We don't need to round shares, they are integral MPT
auto const& vaultDeltaShares = *maybeVaultDeltaShares;
if (vaultDeltaShares.delta * -1 != accountDeltaShares.delta)
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.

If you have the operator above, this can be simplified to:

if (vaultDeltaShares * -1 != accountDeltaShares)

if (*accountDeltaShares <= zero)
// We don't need to round shares, they are integral MPT
auto const& accountDeltaShares = *maybeAccDeltaShares;
if (accountDeltaShares.delta <= zero)
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.

Also if you have a comparison operator this can be simplified

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.

Something like:

bool operator<=>(DeltaInfo const& a, Number const& b)
{
    return operator<=>(a.delta, b);
}

Copy link
Copy Markdown
Collaborator Author

@Tapanito Tapanito Apr 3, 2026

Choose a reason for hiding this comment

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

I'm not convinced by operator<=> against Number. Writing accountDeltaShares <= zero instead of accountDeltaShares.delta <= zero saves 6 characters but hides the fact you're comparing only the delta and ignoring scale. For someone reading the invariant logic, .delta makes the intent explicit. Same concern with operator!= between two DeltaInfos — does it compare scale too? That ambiguity seems worse than the verbosity.

@Tapanito
Copy link
Copy Markdown
Collaborator Author

Tapanito commented Apr 3, 2026

As I was reading this PR, I kept thinking if we can get away from repeated allocation of STAmount objects with rounded off numbers, by instead using a dynamically computed epsilon and doing the almost_equal(flaot1, float2, epsilon) based comparison. I think it will be much more efficient and simple to understand. With my limited understanding of the STAmount and Number classes, I think it will be accurate as well. The epsilon will be calculated similar to scale. If scale is 10^-14, then that becomes the epsilon.
@ximinez Have you already tried the epsilon based comparison approach?
I ran some scenarios on this with AI and asked it to check if the accuracy holds. This is a short summary of my suggestion: (I can share detailed analysis later if you want)
When considering using |a - b| <= 10^minScale instead of rounding both sides via roundToAsset, the scale computation (computeMinScale, DeltaInfo) would stay the same — only the comparison step changes.

Performance:

Each roundToAsset call constructs a temporary STAmount (running canonicalize()) and then roundToScale does an add+subtract trick through Number arithmetic. The deposit path alone has ~6 such calls. An epsilon comparison is a single subtraction + absolute value + compare — significantly cheaper per invariant check.

Robustness at rounding boundaries:

The roundToScale trick (value + ref) - ref applies banker's rounding at the target scale. Two values representing the same logical quantity with noise at digits 17-19 can straddle the rounding boundary at digit 16 and round to different STAmount mantissas:

a = 5,050,000,000,000,000,499 × 10⁻¹⁷   //→  rounds down to ...000 × 10⁻¹⁴
b = 5,050,000,000,000,000,501 × 10⁻¹⁷   //→  rounds up   to ...001 × 10⁻¹⁴

roundToAsset:  round(a) ≠ round(b)  //→  false invariant violation
epsilon:       |a - b| = 2×10⁻¹⁷ < 10⁻¹⁴  //→  correctly equal

This is rare (requires digit 16 at a rounding tie + opposite-direction noise), but interest accrual arithmetic could produce it.
One tradeoff — the > comparison: The single magnitude check vaultDeltaAssets > txAmount (deposit must not exceed tx amount) would become a - b > epsilon, which misses violations of exactly 1 ULP. But a 1-ULP discrepancy (e.g., 10⁻¹⁴ out of 50.5) represents ~0.000000000002% of the value — not a meaningful security concern and real bugs produce much larger mismatches.

Summary:

Across all 14 rounded comparisons in finalize (8 equality, 5 sign, 1 magnitude), epsilon is equivalent or more robust for 13, with a negligible theoretical tradeoff on 1. It's also cheaper to compute.

epsilon based comparisons may be faster but the errors can accumulate after a couple of arithmetic operations and you'll be in the state where you are not sure if the two numbers are not equal or it was because of accumulated error

I am not suggesting to use epsilon for operations, I am suggesting it for comparisons. So, we won't be loosing any precision iteratively.

@Tapanito The current implementation might solve the problem at hand, but it also introduces some latency. Also. the current solution is not a simple one(or even easy to understand and maintain) to be replaced later. It will take equal, if not more, efforts to update it. My suggestion is to just think about the alternate solution, see if it works and if it does, then maybe prefer that one. Specially if this is not high priority fix for currently planned release.

Thanks for the detailed analysis @pratikmankawde.

Regarding performance invariant checks run once per vault transaction, so the cost of a few roundToAsset calls is negligible relative to ledger reads, SLE updates, and hashing. This isn't a hot inner loop where micro-optimization would pay off.

On the rounding boundary edge case — it's a real scenario but requires very specific alignment of mantissa digits at a rounding tie. With Number's 16-digit decimal mantissa and the discrete operations vault transactions perform, this is unlikely in practice.

The tradeoff on magnitude/sign comparisons is actually the more concerning part of the epsilon approach. The invariant checks aren't just equality there are sign checks and > comparisons. With epsilon, a > b becomes a - b > epsilon, which changes the semantic from "did the vault gain assets" to "did the vault gain assets by more than epsilon." For an invariant checker, being precise about direction matters more than being fast.

Also, even though epsilon would only be used for comparison, the values being compared are already products of arithmetic on Numbers. The epsilon needs to account for accumulated representation error in those intermediate values, not just the final comparison step. Getting that right for every comparison path is subtle and error-prone.

We've also simplified the rounding code in this PR (extracted DeltaInfo::makeDelta), which addresses some of the maintainability concern.

…putation

The pattern of computing a delta between two Numbers while taking the
coarsest scale was repeated 3 times (deposit, withdrawal, clawback).
Extract into a static DeltaInfo::makeDelta method.
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.

3 participants