Skip to content

fix: Fix touchy "funds are conserved" assertion in LoanPay#6231

Merged
ximinez merged 11 commits intoripple/staging-313from
ximinez/loanpay-assertion
Apr 14, 2026
Merged

fix: Fix touchy "funds are conserved" assertion in LoanPay#6231
ximinez merged 11 commits intoripple/staging-313from
ximinez/loanpay-assertion

Conversation

@ximinez
Copy link
Copy Markdown
Collaborator

@ximinez ximinez commented Jan 16, 2026

  • Add poorly named "Yield Theft via Rounding Manipulation" test

High Level Overview of Change

IOU rounding when a Vault and a Loan are at significantly different scales can lead to the appearance of lost funds. An assertion in LoanPay wants funds to be "conserved", but that isn't always possible.

Context of Change

The new unit test testYieldTheftRounding was submitted as a finding in ImmuneFi's attackathon. Due to earlier changes in how overpayments are calculated, the "theft" is now a non-issue, but the assertion still was causing problems.

Update the rounding to improve accuracy, and calculate the amounts in a few different ways so if one still rounds unexpectedly, one of the others will not. This sounds weird, and it is, but IOU rounding can be weird. "Sometimes you eat the bear, and sometimes the bear eats you."

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

@ximinez ximinez added the DraftRunCI Normally CI does not run on draft PRs. This opts in. label Jan 16, 2026
@ximinez ximinez changed the title Revert "Bugfix: Adds graceful peer disconnection (#5669)" (#5855) [WIP] Fix touchy "funds are conserved" assertion in LoanPay Jan 16, 2026
@ximinez ximinez force-pushed the ximinez/loanpay-assertion branch from 5a41799 to c1e161c Compare January 16, 2026 19:40
@codecov
Copy link
Copy Markdown

codecov Bot commented Jan 16, 2026

Codecov Report

❌ Patch coverage is 91.66667% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.5%. Comparing base (c62f0e2) to head (5a2aa4f).
⚠️ Report is 1 commits behind head on ripple/staging-313.

Files with missing lines Patch % Lines
src/xrpld/app/tx/detail/LoanPay.cpp 92.2% 6 Missing ⚠️
src/xrpld/app/misc/detail/LendingHelpers.cpp 80.0% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           ripple/staging-313   #6231   +/-   ##
==================================================
  Coverage                79.5%   79.5%           
==================================================
  Files                     840     840           
  Lines                   71817   71890   +73     
  Branches                 8198    8197    -1     
==================================================
+ Hits                    57061   57132   +71     
- Misses                  14756   14758    +2     
Files with missing lines Coverage Δ
include/xrpl/basics/Number.h 98.7% <ø> (ø)
include/xrpl/protocol/STAmount.h 96.2% <ø> (ø)
src/libxrpl/basics/Number.cpp 98.8% <100.0%> (ø)
src/libxrpl/protocol/STAmount.cpp 88.6% <ø> (ø)
src/libxrpl/protocol/STNumber.cpp 94.9% <ø> (ø)
src/xrpld/app/tx/detail/Transactor.cpp 92.8% <ø> (ø)
src/xrpld/app/tx/detail/VaultClawback.cpp 97.4% <ø> (ø)
src/xrpld/app/misc/detail/LendingHelpers.cpp 88.8% <80.0%> (+0.8%) ⬆️
src/xrpld/app/tx/detail/LoanPay.cpp 94.4% <92.2%> (-0.8%) ⬇️

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

@ximinez ximinez force-pushed the ximinez/loanpay-assertion branch from c1e161c to 08170ff Compare January 17, 2026 00:23
@ximinez ximinez changed the title [WIP] Fix touchy "funds are conserved" assertion in LoanPay Fix touchy "funds are conserved" assertion in LoanPay Jan 17, 2026
@ximinez ximinez marked this pull request as ready for review January 17, 2026 00:23
@ximinez ximinez requested a review from a team January 17, 2026 00:23
@ximinez ximinez removed the DraftRunCI Normally CI does not run on draft PRs. This opts in. label Jan 20, 2026
@ximinez ximinez requested a review from Tapanito January 20, 2026 18:48
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
std::minmax_element(exponents.begin(), exponents.end());
// IOU rounding can be interesting. Give a margin of error that reflects
// the orders of magnitude between the extremes.
if (!asset.integral() && *max < STAmount::cMaxOffset * 3 / 4)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I understand that you're using the 3 / 4 division to create a threshold that is an order of magnitude, but perhaps the magic numbers are better place somewhere else, or at least provided as a constant variable? Future generations (most likely one of us), might find this confusing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How about using withinRelativeDistance()?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The tricky thing is how does one determine an acceptable delta?

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.

I understand that you're using the 3 / 4 division to create a threshold that is an order of magnitude, but perhaps the magic numbers are better place somewhere else, or at least provided as a constant variable? Future generations (most likely one of us), might find this confusing.

Hell, I found it confusing when I wrote it. I've simplified it a bit, and it seems to be good enough for now.

ahIGNORE_AUTH,
j_,
SpendableHandling::shFULL_BALANCE);
auto const balanceScale = [&]() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Perhaps this change can be done separately, but wouldn't enforcing that there is no absolute balance change, is better served in Loan invariant?

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.

Perhaps this change can be done separately, but wouldn't enforcing that there is no absolute balance change, is better served in Loan invariant?

It would if I could figure out how to make it more "bulletproof". Even with the approximation I've implemented, I really don't trust that it's not going to have false negatives. With an assertion, no matter how "bad" the check is, it won't be included in release builds, and therefore can't break anything in production.

Additionally, if/when we ever do come up with a bulletproof way of checking, I would want to add a global conservation of funds invariant check, similar to TransfersNotFrozen, which accumulates changes across all trust lines, regardless of the transaction type. If you're going to spend the effort, don't limit yourself to just one transaction. That will probably require a new amendment, and is non-trivial.

Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/test/app/Loan_test.cpp Outdated
Comment thread src/test/app/Loan_test.cpp Outdated
Comment thread src/test/app/Loan_test.cpp Outdated
Comment thread src/test/app/Loan_test.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
std::minmax_element(exponents.begin(), exponents.end());
// IOU rounding can be interesting. Give a margin of error that reflects
// the orders of magnitude between the extremes.
if (!asset.integral() && *max < STAmount::cMaxOffset * 3 / 4)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you please expand on the balance scale logic? Suppose min = 7 and max = 9 and it's IOU. Then new max = 9 + (9 - 7) = 11. Why is it a better scale? I would have expected the lowest scale to be the best.

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.

Could you please expand on the balance scale logic? Suppose min = 7 and max = 9 and it's IOU. Then new max = 9 + (9 - 7) = 11. Why is it a better scale? I would have expected the lowest scale to be the best.

Scale is not always intuitive. It represents the exponent of the STAmount representation of a value. STAmount uses a 16 digit mantissa. So a simple number like "1" would have an exponent of -15. (1,000,000,000,000,000 * 10^-15)

Let's look at an example, which I got by using the minimum scale and waiting for the assertion to fail. These are from the log messages, but formatted for readability.

Before:

  • account 100'999'726.0136946 (exponent: -7)
  • vault 4'000'273.986277955 (exponent: -9)
  • broker 94'500'000.0000274 (exponent: -8)
  • total 199'500'000 (exponent: -7)
    After:
  • account 100'999'452.0273892 (exponent: -7)
  • vault 4'000'547.972555915 (exponent: -9)
  • broker 94'500'000.00005479 (exponent: -8)
  • total 199'499'999.9999999 (exponent -7)
    Changes:
  • account -273.9863054, (exponent: -12)
  • vault 273.98627796, (exponent: -12)
  • broker 0.00002739, (exponent: -16)
  • total -0.00000005 (exponent: -22)

The minimum scale of the balances is -9 (9 digits after the decimal point), and the max scale is -7 (7 digits after the decimal point).

If you round to the minimum scale, it will round all the numbers so they are accurate to 9 digits after the decimal point. That leaves all of these numbers unchanged. So when you add them together, or subtract them to get the "Changes", they are all at different scales, and give values that don't quite match.

Now let's look at what happens if we round to the max scale + 1, which is -6. (I simplified the balanceScale computation.)

Before:

  • total 199'500'000
    After:
  • total 199'500'000
    Changes:
  • total 0

Internally, I don't add up the rounded balances - I round all three totals after adding - so I didn't include them here. That's because rounding the intermediate values can actually introduce errors. But the important thing is that after rounding to 6 digits after the decimal, the values all match.

ahIGNORE_AUTH,
j_,
SpendableHandling::shFULL_BALANCE);
auto const balanceScale = [&]() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

currently these checks are in DEBUG mode. Do we want to bring some checks into RELEASE?

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.

I sort of addressed this question in #6231 (comment), but while answering @gregtatcam's question, I added some logging which could be useful in a release build.

So I just pushed c121d3d

@ximinez ximinez changed the base branch from release-3.1 to ximinez/release-3.1.0-rc2 January 22, 2026 17:20
Base automatically changed from ximinez/release-3.1.0-rc2 to release-3.1 January 22, 2026 23:41
@ximinez ximinez changed the base branch from release-3.1 to ximinez/staging-3.1 January 27, 2026 23:58
@ximinez ximinez changed the base branch from ximinez/staging-3.1 to tapanito/lending-fix-amendment February 4, 2026 22:20
@ximinez ximinez force-pushed the ximinez/loanpay-assertion branch from 87ad005 to 2bc61be Compare February 4, 2026 22:24
@bthomee bthomee requested review from pratikmankawde and vlntb and removed request for Tapanito April 8, 2026 15:22
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.

All my concerns have been addressed

Comment thread src/xrpld/app/misc/detail/LendingHelpers.cpp
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
XRPL_ASSERT_PARTS(
*assetsAvailableProxy <= *assetsTotalProxy,
assetsAvailableAfter <= assetsTotalAfter,
"xrpl::LoanPay::doApply",
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.

Suggested change
"xrpl::LoanPay::doApply",
"rippled::LoanPay::doApply",

Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
Comment thread src/xrpld/app/tx/detail/LoanPay.cpp Outdated
}
if (exponents.empty())
{
UNREACHABLE("xrpl::LoanPay::doApply : all zeroes");
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.

Suggested change
UNREACHABLE("xrpl::LoanPay::doApply : all zeroes");
UNREACHABLE("rippled::LoanPay::doApply : all zeroes");

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.

No issues.

Review by Claude Opus 4.6 · Prompt: V14

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.

No issues.

Review by Claude Opus 4.6 · Prompt: V14

@kennyzlei kennyzlei requested a review from vlntb April 14, 2026 16:05
@ximinez ximinez requested a review from Tapanito April 14, 2026 17:27
@ximinez ximinez dismissed Tapanito’s stale review April 14, 2026 17:40

Since @Tapanito is out for a while, I'm dismissing this review.

@ximinez ximinez added the Ready to merge *PR author* thinks it's ready to merge. Has passed code review. Perf sign-off may still be required. label Apr 14, 2026
@ximinez ximinez merged commit eefa1d0 into ripple/staging-313 Apr 14, 2026
46 of 48 checks passed
@ximinez ximinez deleted the ximinez/loanpay-assertion branch April 14, 2026 22:09
@ximinez ximinez added this to the 3.1.3 milestone Apr 15, 2026
@ximinez ximinez restored the ximinez/loanpay-assertion branch April 17, 2026 20:14
@ximinez ximinez deleted the ximinez/loanpay-assertion branch April 17, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready to merge *PR author* thinks it's ready to merge. Has passed code review. Perf sign-off may still be required.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants