Skip to content

fix: Add rounding to Vault invariants#6632

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

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

Conversation

@Tapanito
Copy link
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

codecov bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 94.81481% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.5%. Comparing base (2c765f6) to head (0c060dc).
⚠️ Report is 2 commits behind head on develop.

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

Impacted file tree graph

@@           Coverage Diff           @@
##           develop   #6632   +/-   ##
=======================================
  Coverage     81.4%   81.5%           
=======================================
  Files          998     998           
  Lines        74443   74519   +76     
  Branches      7563    7558    -5     
=======================================
+ Hits         60632   60703   +71     
- Misses       13811   13816    +5     
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 97.4% <94.7%> (-0.8%) ⬇️

... and 1 file 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
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
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
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
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
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
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.

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