Compare commits

...

6 Commits

Author SHA1 Message Date
Vito
92517efa0b test: Count the dust reservoir in O3's conservation sum 2026-08-06 19:03:50 +02:00
Vito
d175449eb5 test: Make the dust suite red on base, green when fixed
The shared VaultRounding suite asserted the dust oracles behind an
if-constexpr capability flag, so it passed on every branch while asserting
different things. The post-fix arms were therefore never executed, which is
how a recognition-delta derivation valid only pre-fix survived review.

Assert the oracles unconditionally instead. Tier 2 now fails on this branch
because the dust has nowhere to go, and that failure is the bug's
demonstration; each solution branch makes it pass by giving the dust a home.
Tier 1 still passes everywhere, so a Tier 1 failure remains a real bug on
any branch.

Drop kHasDustReservoir; readVaultDust alone is the seam. Add
testDustDisappears, which states the whole bug as one identity:
AssetsTotal == AssetsAvailable + principal owed, with no probe and no
interest/principal decomposition.
2026-08-06 16:47:18 +02:00
Vito
0a2377c0f6 test: Derive oracle inputs per-branch in VaultRounding suite
RepaymentResult carried a baked-in ΔAssetsTotal-based derivation of
recognitionDelta and raw that is only valid on this branch, where
T += assetsTotalDelta is the only write to sfAssetsTotal during
LoanPay. On a solution branch the law is ΔT = r − ΔD (maths doc §4),
so that same expression silently yields r − ΔD instead of r, which
makes O4 collapse to "ΔD == 0" — true only when there is no dust,
false in exactly the case these tests exist to exercise. Both
solution branches would have inherited spuriously-failing assertions
from a file they are not allowed to edit.

RepaymentResult now carries only raw observations (accounting fields,
the dust probe, the Loan's own principal, and the real balances of
the borrower and every possible fee-leg recipient). Each caller's own
if-constexpr arm derives recognitionDelta/raw for itself: the pre-fix
arm keeps the branch-local ΔAssetsTotal shortcut (valid only here),
the post-fix arm derives raw from real balances alone
(raw = -Δborrower - ΔfeeRecipient), which holds regardless of which
dust mechanism, if any, is in force.

Also adds an O8 (T == A + PO) corroboration to testDustCreatedOnRepayment
and to testCharacterizeCurrentRounding's post-fix arm, since O8 needs
no probe and no r/raw derivation at all - the testsuite doc now
prefers it wherever it applies. Adds the fixture-level d != 0 assertion
testsuite doc section 5 item 3 requires. Characterisation literals are
unchanged (dust is still exactly 5e-12).
2026-08-06 16:21:49 +02:00
Vito
85cf5574fa style: Fix clang-tidy include-cleaner and brace findings
Fixes the include-order/style findings clang-tidy raised against the
two prior commits: VaultHelpers.cpp's new #include lines used quotes
instead of angle brackets and were out of order, and
VaultRounding_test.cpp had a few unused includes and a missing pair
of braces on an if/else.
2026-08-06 15:26:29 +02:00
Vito
63aec2b301 refactor: Centralize Vault accounting-field writes in a helper
Adds addAssetsToVault, removeAssetsFromVault, and closeVaultAssets to
VaultHelpers, and converts every direct write to sfAssetsTotal /
sfAssetsAvailable in the seven call sites (VaultDeposit, VaultWithdraw
x2, VaultClawback, LoanSet, LoanPay, LoanManage) to go through them.
VaultCreate's literal zero initialization is left alone and commented
as the one deliberate exception.

This is a pure bookkeeping refactor: each call performs exactly the
same field updates the call site did before, in the same order, with
no rounding, gating, or transfer logic added. Existing per-site guards
(LoanPay's assert, LoanManage's dust-snap-then-check) stay exactly
where they are, re-reading the fields the helper wrote.
2026-08-06 15:22:51 +02:00
Vito
18b471b43e test: Add shared VaultRounding suite and dust-probe seam
Adds VaultDustProbe.h, the single seam through which the shared test
suite reads a Vault's dust reservoir, and VaultRounding_test.cpp, the
suite both solution branches are verified against. On this branch
kHasDustReservoir is false and readVaultDust always returns zero, so
every tier-2 test takes its pre-fix arm.

testCharacterizeCurrentRounding harvests exact literals from a real
run and demonstrates the leak directly: paying off a loan whose scale
is finer than the vault's current scale credits the vault less than
the loan's own receivable falls by, with the difference going
nowhere.
2026-08-06 15:21:38 +02:00
11 changed files with 1255 additions and 19 deletions

View File

@@ -1,11 +1,16 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <expected>
#include <optional>
namespace xrpl {
@@ -123,4 +128,72 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
[[nodiscard]] VaultVersion
getVaultVersion(SLE::const_ref vault);
/**
* The single owner of every write to a Vault's two accounting fields,
* sfAssetsAvailable and sfAssetsTotal, for a cash-moving-in operation
* (deposit, repayment, or default settlement).
*
* On this branch this is a pure bookkeeping refactor with no behaviour
* change: it performs exactly
* sfAssetsAvailable += cashIn
* sfAssetsTotal += recognitionDelta
* view.update(vault)
* and nothing else. In particular it does NOT enforce
* sfAssetsAvailable <= sfAssetsTotal — callers keep their own guard exactly
* where it is today, because that guard is order-sensitive and, at at least
* one call site (LoanManage::defaultLoan), the fields legitimately cross
* transiently before an existing post-write correction runs.
*
* @param view The ApplyView to mutate.
* @param vault The vault SLE (mutated in place; caller retains ownership).
* @param cashIn The amount of cash arriving at the vault's main custody.
* Must already be rounded to whatever scale the caller's
* transactor uses; this function performs no rounding.
* @param recognitionDelta The signed amount sfAssetsTotal should recognise,
* independently of cashIn.
* @param j Journal (currently unused; reserved for the dust-mechanism
* amendment gate that lands on the solution branches).
*
* @return What sfAssetsAvailable actually moved by. On this branch that is
* always exactly cashIn.
*/
[[nodiscard]] std::expected<Number, TER>
addAssetsToVault(
ApplyView& view,
SLE::ref vault,
Number const& cashIn,
Number const& recognitionDelta,
beast::Journal j);
/**
* The counterpart of addAssetsToVault for a cash-moving-out operation
* (withdrawal, clawback, or loan funding). Performs
* sfAssetsAvailable -= cashOut
* sfAssetsTotal += recognitionDelta
* view.update(vault)
* See addAssetsToVault for the guard-placement rationale.
*
* @return What sfAssetsAvailable actually moved by (as a negative number).
* On this branch that is always exactly -cashOut.
*/
[[nodiscard]] std::expected<Number, TER>
removeAssetsFromVault(
ApplyView& view,
SLE::ref vault,
Number const& cashOut,
Number const& recognitionDelta,
beast::Journal j);
/**
* The terminal-withdrawal entry point: assigns both accounting fields to
* zero. This is an assignment, not a delta, which is why it is a separate
* function rather than a degenerate call to removeAssetsFromVault
* (VaultWithdraw.cpp's "Do not let dust accumulate in the Vault" branch).
*
* @return sfAssetsAvailable's value immediately before it was zeroed (i.e.
* what the caller still owes the departing shareholder).
*/
[[nodiscard]] std::expected<Number, TER>
closeVaultAssets(ApplyView& view, SLE::ref vault, beast::Journal j);
} // namespace xrpl

View File

@@ -1,7 +1,9 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
@@ -11,8 +13,10 @@
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <cstdint>
#include <expected>
#include <optional>
#include <utility>
@@ -157,4 +161,55 @@ getVaultVersion(SLE::const_ref vault)
return static_cast<VaultVersion>(version);
}
[[nodiscard]] std::expected<Number, TER>
addAssetsToVault(
ApplyView& view,
SLE::ref vault,
Number const& cashIn,
Number const& recognitionDelta,
beast::Journal j)
{
(void)j;
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::addAssetsToVault : valid Vault sle");
vault->at(sfAssetsAvailable) += cashIn;
vault->at(sfAssetsTotal) += recognitionDelta;
view.update(vault);
return cashIn;
}
[[nodiscard]] std::expected<Number, TER>
removeAssetsFromVault(
ApplyView& view,
SLE::ref vault,
Number const& cashOut,
Number const& recognitionDelta,
beast::Journal j)
{
(void)j;
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::removeAssetsFromVault : valid Vault sle");
vault->at(sfAssetsAvailable) -= cashOut;
vault->at(sfAssetsTotal) += recognitionDelta;
view.update(vault);
return -cashOut;
}
[[nodiscard]] std::expected<Number, TER>
closeVaultAssets(ApplyView& view, SLE::ref vault, beast::Journal j)
{
(void)j;
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::closeVaultAssets : valid Vault sle");
Number const assetsAvailable = vault->at(sfAssetsAvailable);
vault->at(sfAssetsTotal) = Number(0);
vault->at(sfAssetsAvailable) = Number(0);
view.update(vault);
return assetsAvailable;
}
} // namespace xrpl

View File

@@ -8,6 +8,7 @@
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -189,10 +190,19 @@ LoanManage::defaultLoan(
auto const vaultDefaultRounded = roundToAsset(
vaultAsset, vaultDefaultAmount, vaultScale, Number::RoundingMode::Downward);
vaultTotalProxy -= vaultDefaultRounded;
// Increase the Asset Available of the Vault by liquidated First-Loss
// Capital and any unclaimed funds amount:
vaultAvailableProxy += defaultCovered;
// Capital and any unclaimed funds amount, and decrease the Total
// Value by the (rounded) default amount. The snap-to-match
// workaround and the tecINTERNAL guard immediately below MUST stay
// here, after the helper call, re-reading the fields it wrote —
// see VaultHelpers.h's addAssetsToVault doc comment and base-branch
// plan §5.2: this is the one call site where AssetsAvailable can
// transiently exceed AssetsTotal, and an in-helper guard would fire
// before this code gets a chance to repair it.
if (auto const result =
addAssetsToVault(view, vaultSle, defaultCovered, -vaultDefaultRounded, j);
!result)
return result.error(); // LCOV_EXCL_LINE
if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral())
{
auto const difference = vaultAvailableProxy - vaultTotalProxy;

View File

@@ -9,6 +9,7 @@
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
@@ -485,8 +486,12 @@ LoanPay::doApply()
}
#endif
assetsAvailableProxy += totalPaidToVaultRounded;
assetsTotalProxy += assetsTotalDelta;
// NOTE: totalPaidToVaultRounded, not totalPaidToVaultRaw — the switch to
// raw belongs to the dust-mechanism amendment, not this refactor.
if (auto const result =
addAssetsToVault(view, vaultSle, totalPaidToVaultRounded, assetsTotalDelta, j_);
!result)
return result.error(); // LCOV_EXCL_LINE
XRPL_ASSERT_PARTS(
*assetsAvailableProxy <= *assetsTotalProxy,

View File

@@ -10,6 +10,7 @@
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -634,13 +635,14 @@ LoanSet::doApply()
view.insert(loan);
// Update the balances in the vault
vaultAvailableProxy -= principalRequested;
vaultTotalProxy += assetsTotalDelta;
if (auto const result =
removeAssetsFromVault(view, vaultSle, principalRequested, assetsTotalDelta, j_);
!result)
return result.error(); // LCOV_EXCL_LINE
XRPL_ASSERT_PARTS(
*vaultAvailableProxy <= *vaultTotalProxy,
"xrpl::LoanSet::doApply",
"assets available must not be greater than assets outstanding");
view.update(vaultSle);
// Update the balances in the loan broker
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);

View File

@@ -383,9 +383,10 @@ VaultClawback::doApply()
if (sharesDestroyed == beast::kZero)
return tecPRECISION_LOSS;
assetsTotal -= assetsRecovered;
assetsAvailable -= assetsRecovered;
view().update(vault);
if (auto const result =
removeAssetsFromVault(view(), vault, assetsRecovered, -assetsRecovered, j_);
!result)
return result.error(); // LCOV_EXCL_LINE
auto const& vaultAccount = vault->at(sfAccount);
// Transfer shares from holder to vault.

View File

@@ -222,6 +222,11 @@ VaultCreate::doApply()
vault->at(sfSequence) = sequence;
vault->at(sfOwner) = accountID_;
vault->at(sfAccount) = pseudoId;
// Deliberate exception to "every write to sfAssetsTotal /
// sfAssetsAvailable goes through VaultHelpers.h's addAssetsToVault /
// removeAssetsFromVault / closeVaultAssets" (base-branch plan §5.5):
// these are literal zero initializations on a brand-new object, with no
// rounding discipline to get wrong.
vault->at(sfAssetsTotal) = Number(0);
vault->at(sfAssetsAvailable) = Number(0);
vault->at(sfLossUnrealized) = Number(0);

View File

@@ -314,9 +314,9 @@ VaultDeposit::doApply()
sharesCreated.asset() != assetsDeposited.asset(),
"xrpl::VaultDeposit::doApply : assets are not shares");
vault->at(sfAssetsTotal) += assetsDeposited;
vault->at(sfAssetsAvailable) += assetsDeposited;
view().update(vault);
if (auto const result = addAssetsToVault(view(), vault, assetsDeposited, assetsDeposited, j_);
!result)
return result.error(); // LCOV_EXCL_LINE
// A deposit must not push the vault over its limit.
auto const maximum = *vault->at(sfAssetsMaximum);

View File

@@ -339,15 +339,16 @@ VaultWithdraw::doApply()
assetsWithdrawn = allAvailable;
// Do not let dust accumulate in the Vault.
assetsTotal = 0;
assetsAvailable = 0;
if (auto const result = closeVaultAssets(view(), vault, j_); !result)
return result.error(); // LCOV_EXCL_LINE
}
else
{
assetsTotal -= assetsWithdrawn;
assetsAvailable -= assetsWithdrawn;
if (auto const result =
removeAssetsFromVault(view(), vault, assetsWithdrawn, -assetsWithdrawn, j_);
!result)
return result.error(); // LCOV_EXCL_LINE
}
view().update(vault);
auto const& vaultAccount = vault->at(sfAccount);

View File

@@ -0,0 +1,54 @@
#pragma once
// Vault Dust — the probe seam.
//
// Where a Vault's un-recognized remainder ("dust") is stored differs per
// implementation:
// - the base branch has no dust mechanism at all;
// - solution A (…-pseudo-account) stores it as the balance of a second
// pseudo-account;
// - solution B' (…-trustline-dust) stores it as a signed field beside the
// balance on the Vault's custody trust line.
//
// This header is the ONLY place the shared test suite
// (src/test/app/lending/VaultRounding_test.cpp) is allowed to know about
// that difference, and it is the ONLY file that may differ between the two
// solution branches. Do not reference sfDustAccount, sfDust, or any other
// implementation-specific field anywhere else in a test — route every such
// read through readVaultDust() below.
//
// readVaultDust() below is the whole seam: each solution branch reimplements
// its body and changes nothing else. There is deliberately no "does this
// build have a reservoir?" flag — the shared suite asserts the post-fix
// oracles unconditionally, so on the base branch it fails, and that failure
// is the bug's demonstration (see the RED/GREEN CONTRACT note in
// VaultRounding_test.cpp).
#include <test/jtx/Env.h>
#include <xrpl/basics/Number.h>
#include <xrpl/protocol/Keylet.h>
namespace xrpl::test {
// Per-Vault: how much dust does this Vault currently hold, normalized to
// Vault-pseudo-account terms (i.e. a positive Number means "the Vault is
// carrying this much unrecognized value on the borrower's behalf").
//
// Returns zero when the build has no reservoir (this branch), and also when
// this particular Vault has none.
//
// `inline` is not optional: this header is included both by the shared
// suite and by each branch's own per-solution test file, so a non-inline
// definition would be a duplicate-symbol link error. If a solution's
// implementation grows past a few lines, move the body to a
// VaultDustProbe.cpp beside this header rather than dropping `inline`.
[[nodiscard]] inline Number
readVaultDust(jtx::Env const& env, Keylet const& vaultKeylet)
{
(void)env;
(void)vaultKeylet;
return Number{};
}
} // namespace xrpl::test

File diff suppressed because it is too large Load Diff