Skip to content

fix: Reject vault deposits that move nothing from the depositor - #8014

Open
tyalymov wants to merge 2 commits into
developfrom
tialymov/FN-86-deposit_share_truncation_sub_ulp
Open

fix: Reject vault deposits that move nothing from the depositor#8014
tyalymov wants to merge 2 commits into
developfrom
tialymov/FN-86-deposit_share_truncation_sub_ulp

Conversation

@tyalymov

Copy link
Copy Markdown
Contributor

High Level Overview of Change

A VaultDeposit can mint shares against a transfer that never happened, which trips the vault
invariant and fails the transaction with tecINVARIANT_FAILED. Behind fixCleanup3_4_0, such a
deposit is now rejected with tecPRECISION_LOSS before any state changes.

Context of Change

A deposit does not transfer the requested amount. It transfers that amount truncated to a whole
number of shares and converted back (assetsToSharesDeposit, then sharesToAssetsDeposit), which
can be smaller. The gap opens up once the vault's assets-to-shares ratio stops being a power of
ten, and that happens through ordinary use: LoanPay books accrued interest into AssetsTotal
while the share supply stays put.

When the transferred value lands below half a ULP at the depositor's own trust-line scale, the
debit rounds away to nothing. The depositor's balance does not change, the vault still books the
assets and mints the shares, and ValidVault catches the mismatch at finalize time ("deposit must
decrease depositor balance").

The precision guard added by fixCleanup3_2_0 does not prevent this. It tests the raw requested
amount, which is large enough to survive rounding, rather than the smaller value that is actually
transferred, so the failure is reachable both with and without that amendment.

Under fixCleanup3_4_0, preclaim recomputes the share round-trip and rejects the deposit up
front, and doApply repeats the check on the value it computed itself, before the vault fields are
touched. The asset issuer is exempt: accountHolds reports the maximum representable value for the
issuer, which would make every amount look sub-ULP.

One existing case moves with the amendment. The pre-fixCleanup3_2_0 run of
testVaultDepositCanonicalizeToZero now disables fixCleanup3_4_0 as well, because the new
depositor-side check rejects that deposit too; the invariant is only reachable with neither guard
active.

API Impact

  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)

The change is consensus-affecting and gated on fixCleanup3_4_0. Before activation, behavior is
unchanged.

Test Plan

testBugDepositShareTruncationSubUlp in Vault_test builds the ratio through real transactions: a
1000 USD deposit, a loan broker with a one-year bullet loan at 24%, then a LoanPay that leaves the
vault holding 1240 assets against 1000 shares. A depositor sitting on the 1e16 IOU precision cusp,
where one ULP is 10, then deposits 6 USD, which truncates to 4 shares worth 4.96.

It runs four times: fixCleanup3_4_0 on and off, each crossed with fixCleanup3_2_0 on and off.
With the amendment off the deposit still ends in tecINVARIANT_FAILED; with it on the deposit is
rejected with tecPRECISION_LOSS.

A deposit does not transfer the requested amount. It transfers that
amount truncated to a whole number of shares and converted back, which
can be smaller, and the gap grows once the vault's assets-to-shares
ratio stops being a power of ten. LoanPay does exactly that when it
books accrued interest into AssetsTotal without minting shares.

If the transferred value lands below half a ULP at the depositor's own
trust-line scale, the debit rounds away to nothing: the depositor keeps
their balance while the vault books the assets and mints shares, and
the vault invariant fails the transaction after the fact.

Under fixCleanup3_4_0, check the post-truncation value against the
depositor's scale and reject with tecPRECISION_LOSS before any state
moves. preclaim recomputes the round-trip and rejects up front; doApply
repeats the check on the value it computed itself, so the transfer can
never be a no-op for the depositor. The asset issuer is exempt, since
accountHolds reports the maximum representable value for them.

The existing pre-fixCleanup3_2_0 deposit-canonicalization case now
disables fixCleanup3_4_0 as well: the new check rejects that deposit
too, so the invariant is only reachable with neither guard in place.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

This change adds a depositor-side rounding guard for VaultDeposit under fixCleanup3_4_0, gated in both preclaim and doApply, with a well-targeted regression test (testBugDepositShareTruncationSubUlp) that reproduces the truncation-to-zero scenario via a real loan/interest flow. The logic in roundsToZeroForDepositor correctly short-circuits for integral assets (XRP/MPT) and for the issuer account (whose accountHolds balance is unbounded), and the preclaim/doApply checks mirror each other using the actual transferred value rather than the raw requested amount, closing the gap left by the fixCleanup3_2_0 guard. The existing testVaultDepositCanonicalizeToZero case is correctly updated to also disable fixCleanup3_4_0 so the invariant-failure path stays reachable. I did not find correctness, security, or resource-management issues in the added/changed lines; the exception handling (catching std::overflow_error only) and warn-level logging are consistent with the described design intent and don't appear to be bugs.

clang-tidy's bugprone-empty-catch flags the empty handler, and fairly so.
There is nothing to decide in it: an amount that overflows Number is
unusable for a different reason than the one this check is about, and
doApply still reports that case as tecPATH_DRY. Say as much in the log.

The message sits at debug level, like the one doApply writes for the same
overflow, because a large enough Scale runs into it easily and the line
would otherwise be noise.

@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.

The change is a well-scoped, well-tested precision guard: it recomputes the actual (post-truncation) transferred amount and rejects deposits where the depositor's own debit would round away to nothing, both in preclaim (early rejection) and doApply (authoritative check before vault state is touched). The reasoning, exception handling, and regression test (testBugDepositShareTruncationSubUlp) are solid. One area worth double-checking before merge: the new roundsToZeroForDepositor helper derives its rounding-scale from the depositor's current balance via accountHolds, and none of the added tests (or, seemingly, existing deposit tests) exercise a depositor whose balance in the asset is exactly zero (i.e. a genuinely first-time depositor) at deposit time.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Tapanito Tapanito added this to the 3.4.0 milestone Aug 13, 2026

@gregtatcam gregtatcam 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.

Add a scenario where spendable and balance diverge. The current cases have no reverse limit on bob's line, so spendable equals the balance and they pass whether the guard reads FullBalance or SimpleBalance. Add a depositor in debt on the line — same vault setup, default IOU scale:

env(trust(gw, depositor["USD"](10'000'000'001)));   // gw accepts depositor's own USD
env(pay(depositor, bob, usd(10'000'000'000)));      // sfBalance = -1e10, spendable = 1
env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key,
                   .amount = STAmount{usd.raw(), Number{4, -6}}}), Ter(expected));

4e-6 truncates to 3 shares worth 3.72e-6, under half a ULP of -1e10. Expect tecINVARIANT_FAILED today with fixCleanup3_4_0 either on or off, tecPRECISION_LOSS once the guard reads the raw balance. That case is what pins the fix.

FreezeHandling::ZeroIfFrozen,
AuthHandling::ZeroIfUnauthorized,
j,
SpendableHandling::FullBalance);

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 reads the balance with SpendableHandling::FullBalance, which for a trust line returns balance + counterparty's limit, not the balance. The rounding of the debit is governed by the magnitude of the stored sfBalance. When the two diverge — negative balance plus a large opposite limit — scale(balance, …) is far too fine and the check passes on an amount that still rounds away.

Suggested fix, which also removes the scale reasoning entirely: compare the balance against itself post-debit rather than inferring a ULP.

auto const balance = accountHolds(view, account, assets.asset(), ...,   SpendableHandling::SimpleBalance);
if (balance - assets != balance)
    return false;

// assetsTotal/sharesTotal becomes 1240/1000.
env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
env(pay(carol, loanKeylet.key, usd(2'000).value()), Ter(tesSUCCESS));
env.close();

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.

Pin the ratio the test depends on. After the LoanPay, assert the setup actually produced what the comment claims, so the test can't silently stop exercising the bug:

auto const sleVault = env.le(vaultKeylet);
BEAST_EXPECT(sleVault && *sleVault->at(sfAssetsTotal) == Number{1'240});
auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
BEAST_EXPECT(sleIssuance && sleIssuance->at(sfOutstandingAmount) == 1'000);

@Tapanito Tapanito 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 in all LGTM, left a few nitpicks.

Comment on lines +233 to +254
if (fix340Enabled)
{
try
{
if (auto const shares = assetsToSharesDeposit(vault, sleIssuance, roundedAmount))
{
auto const assets = sharesToAssetsDeposit(vault, sleIssuance, *shares);
if (assets && roundsToZeroForDepositor(ctx.view, account, *assets, ctx.j))
return tecPRECISION_LOSS;
}
}
catch (std::overflow_error const&)
{
// A large enough Scale overflows Number easily, so this stays at debug to avoid
// spamming the log. Nothing to decide here: the amount is unusable for a different
// reason than the one this check is about, and doApply reports it as tecPATH_DRY.
JLOG(ctx.j.debug()) << "VaultDeposit: overflow error computing deposited assets"
<< " with scale=" << static_cast<int>(vault->at(sfScale))
<< ", amount=" << roundedAmount;
}
}

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.

The rule of thumb is to perform as much validation as possible in preclaim. However, in this case I'd argue it is okay to make an exception, and perform the entire validation in doApply. We already have a check there to check if we are issuing zero shares.

// rather than recomputed, so the transfer below can never be a no-op for the depositor.
if (fix340Enabled && roundsToZeroForDepositor(view(), accountID_, assetsDeposited, j_))
return tecPRECISION_LOSS; // LCOV_EXCL_LINE

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.

nit: Let's keep the deposit amount validation in a single place. That way, when we'll get to moving this code into a single helper, it'll be much easier to reason through. Please move this check to line 364.

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 addresses a consensus-critical VaultDeposit edge case where share truncation can result in the depositor’s trust-line debit rounding to zero (no value actually transferred) while the vault still books assets and mints shares, ultimately triggering tecINVARIANT_FAILED. Behind fixCleanup3_4_0, the transaction is now rejected early with tecPRECISION_LOSS to prevent any inconsistent state transitions.

Changes:

  • Add a new depositor-side “rounds-to-zero” guard for the post share-truncation transferred amount in VaultDeposit (checked in both preclaim and doApply) under fixCleanup3_4_0.
  • Update and extend unit tests to cover the share-truncation + sub-ULP rounding scenario across amendment combinations, and adjust an existing invariant-reachability test to disable fixCleanup3_4_0 as well.

Reviewed changes

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

File Description
src/test/app/Vault_test.cpp Adds a new regression test for share-truncation deposits that round away at depositor scale; updates an existing test scenario to reflect the new guard under fixCleanup3_4_0.
src/libxrpl/tx/transactors/vault/VaultDeposit.cpp Introduces roundsToZeroForDepositor and uses it to reject deposits where the actually-transferred amount would not change the depositor’s balance under fixCleanup3_4_0.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +52 to +54
// True if debiting `assets` would leave the depositor's balance untouched, because the value is
// below half a ULP at the scale that balance is held at. Such a deposit would mint shares against
// a transfer that never happened.
Comment on lines +237 to +241
if (auto const shares = assetsToSharesDeposit(vault, sleIssuance, roundedAmount))
{
auto const assets = sharesToAssetsDeposit(vault, sleIssuance, *shares);
if (assets && roundsToZeroForDepositor(ctx.view, account, *assets, ctx.j))
return tecPRECISION_LOSS;
@github-actions

Copy link
Copy Markdown

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

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