Compare commits

...

8 Commits

Author SHA1 Message Date
Vito
c47974622b Merge remote-tracking branch 'origin/develop' into tapanito/vault-clawback-bug 2026-08-24 16:27:44 +02:00
Vito
1613cf8e3b refactor: Deduplicate round-trip overshoot test setup
Extract the shared vault/loan-broker/loan scaffolding used by
testBugClawbackRoundTripOvershoot and testBugWithdrawRoundTripOvershoot
into makeRoundTripOvershootVault, since both tests only differ in the
transaction they run once the (assetsTotal=7, sharesTotal=5) state is
reached.
2026-08-24 11:49:02 +02:00
Vito
8073eb0946 fix: unit-tests 2026-08-24 11:41:55 +02:00
Vito Tumas
132073f46d Merge branch 'develop' into tapanito/vault-clawback-bug 2026-08-24 11:22:33 +02:00
Vito
63a0598bea chore: Apply clang-tidy fixups on VaultBugs_test
Rename expected2_8 / expected4_2 to expectedPost / expectedPre
(readability-identifier-naming rejects underscores between digits;
the numeric initializer still shows the actual value). Add direct
include of ApplyView.h and drop unused STNumber.h, per
include-cleaner.
2026-08-21 15:28:41 +02:00
Vito
8065c73fc9 Merge remote-tracking branch 'origin/develop' into tapanito/vault-clawback-bug
# Conflicts:
#	src/test/app/vault/VaultBugs_test.cpp
2026-08-21 15:20:55 +02:00
Vito
8cd263ce51 refactor: Simplify vault overrun fix comments
Trim the docstrings in VaultClawback, VaultWithdraw, VaultBugs_test,
and VaultScale_test down to the essentials. Drop worked examples
that repeat the same 7/5/4 scenario in multiple places, drop
narrative asides on the test harness, and drop external tracker
references.
2026-08-21 12:57:02 +02:00
Vito
e6d156752a fix: Prevent vault clawback and withdraw overrun
VaultClawback::assetsToClawback and VaultWithdraw::doApply convert
the requested asset amount into shares with assetsToSharesWithdraw
using TruncateShares::No (round-to-nearest), then round-trip back
through sharesToAssetsWithdraw. Whenever the share rounding rounds
up, the round-tripped asset amount can be strictly greater than the
caller's requested amount. For example, with assetsTotal=7 and
sharesTotal=5, requesting 4 yields shares=round(20/7)=3 and
assets=7*3/5=4.2 — 0.2 more than asked.

For clawback this lets the issuer strip more assets from the holder
than the transaction requested. For withdraw it lets the depositor
receive more than requested and, worse, can bypass the preclaim
canWithdraw check on the destination: preclaim validates trust-line
and MPToken limits against the requested amount, but doApply then
delivers a strictly larger amount that a limit-bound destination is
not actually able to receive.

Gate a behavior change under fixCleanup3_4_0 that switches the
asset-denominated branch of both transactors to TruncateShares::Yes.
The round-tripped asset amount is then <= the requested amount by
construction, matching the "vault guarantees up to N" semantic
already used by the clamp branch of VaultClawback. At worst the
transaction underdelivers by less than one asset-per-share; if it
underdelivers all the way to zero shares, the existing
tecPRECISION_LOSS guard fires so the request fails cleanly rather
than silently doing "approximately" the requested action.

Adds bug-reproduction tests in VaultBugs_test.cpp for both
transactors that inject an inconsistent (assetsTotal=7,
sharesTotal=5) state into the open ledger and exercise pre- and
post-amendment behavior. Updates the affected VaultScale_test cases
whose expected shares/assets shifted under the new truncation rule.
2026-08-21 12:41:40 +02:00
4 changed files with 310 additions and 57 deletions

View File

@@ -271,8 +271,15 @@ VaultClawback::assetsToClawback(
}
else
{
// Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
// round-trip back to assets could exceed clawbackAmount.
// Post-amendment: truncate shares so assetsRecovered <=
// clawbackAmount by construction (matches the clamp branch
// below).
auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes
: TruncateShares::No;
auto const maybeShares =
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount);
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate);
if (!maybeShares)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;

View File

@@ -305,9 +305,20 @@ VaultWithdraw::doApply()
if (amount.asset() == vaultAsset)
{
// Fixed assets, variable shares.
//
// Pre-fixCleanup3_4_0: shares were rounded to nearest, so the
// round-trip back to assets could exceed the requested amount.
// That over-delivers to the depositor and can bypass the
// preclaim canWithdraw check on the destination, which was
// validated against the requested amount only.
// Post-amendment: truncate shares so assetsWithdrawn <=
// requested amount by construction. If truncation yields zero
// shares, the tecPRECISION_LOSS guard below fires.
auto const truncate =
view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No;
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss);
vault, sleIssuance, amount, truncate, waiveUnrealizedLoss);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesRedeemed = *maybeShares;

View File

@@ -21,6 +21,7 @@
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
@@ -32,6 +33,7 @@
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <tuple>
#include <utility>
@@ -974,6 +976,242 @@ private:
}
}
// Shared setup for testBugClawbackRoundTripOvershoot and
// testBugWithdrawRoundTripOvershoot, which both need a vault at
// assetsTotal=7, sharesTotal=5 and differ only in what they do once
// that state is reached.
//
// The (7, 5) state is reached through ordinary transactions: a 5 USD
// deposit mints 5 shares 1:1, then a loan broker on the vault issues a
// single-payment bullet loan for the full 5 USD at 40% interest. When
// the borrower repays a year later, LoanPay books the 2 USD of accrued
// interest into sfAssetsTotal without minting shares, leaving
// assetsTotal=7 against sharesTotal=5 (see
// testBugDepositShareTruncationSubUlp for the same technique in more
// detail).
struct RoundTripOvershootVault
{
test::jtx::Account issuer;
test::jtx::Account holder;
PrettyAsset usd;
test::jtx::Vault vault;
Keylet vaultKeylet;
Number initialAssetsTotal;
Number initialAssetsAvailable;
};
std::optional<RoundTripOvershootVault>
makeRoundTripOvershootVault(test::jtx::Env& env)
{
using namespace test::jtx;
using namespace loan_broker;
using namespace loan;
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const holder{"holder"};
Account const borrower{"borrower"};
env.fund(XRP(10'000), issuer, owner, holder, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const usd = issuer["USD"];
env.trust(usd(1'000), owner);
env.trust(usd(1'000), holder);
env.trust(usd(1'000), borrower);
env.close();
env(pay(issuer, holder, usd(100)));
env(pay(issuer, borrower, usd(100)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
// Holder deposits 5 USD, minting 5 shares 1:1.
env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)}));
env.close();
// A loan broker on the vault, then a single bullet loan for the
// entire deposit at 40% interest, one payment, one year out.
auto const brokerKeylet =
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
env(set(owner, vaultKeylet.key));
env.close();
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
env(set(borrower, brokerKeylet.key, usd(5).value()),
loan::kInterestRate(percentageToTenthBips(40)),
kGracePeriod(60),
kPaymentInterval(365 * 24 * 60 * 60),
kPaymentTotal(1),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// Advance to just before the single payment falls due and let the
// borrower repay principal plus interest. Share supply stays at 5,
// so assetsTotal/sharesTotal becomes 7/5.
env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600});
env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS));
env.close();
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return std::nullopt;
auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal);
Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable);
BEAST_EXPECT(initialAssetsTotal == usd(7).number());
BEAST_EXPECT(initialAssetsAvailable == usd(7).number());
{
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID));
if (!BEAST_EXPECT(sleIssuance))
return std::nullopt;
BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5);
}
return RoundTripOvershootVault{
.issuer = issuer,
.holder = holder,
.usd = usd,
.vault = vault,
.vaultKeylet = vaultKeylet,
.initialAssetsTotal = initialAssetsTotal,
.initialAssetsAvailable = initialAssetsAvailable};
}
// VaultClawback::assetsToClawback converts clawbackAmount to shares
// with round-to-nearest, then round-trips back to assets. When shares
// round up, assetsRecovered can exceed clawbackAmount.
//
// Repro: assetsTotal=7, sharesTotal=5, request 4:
// shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
//
// Post-fixCleanup3_4_0: truncate shares so assetsRecovered <=
// clawbackAmount by construction.
void
testBugClawbackRoundTripOvershoot()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, bool withFix) {
Env env{*this, features};
auto const setup = makeRoundTripOvershootVault(env);
if (!BEAST_EXPECT(setup))
return;
auto const clawbackAmount = setup->usd(4);
env(setup->vault.clawback(
{.issuer = setup->issuer,
.id = setup->vaultKeylet.key,
.holder = setup->holder,
.amount = clawbackAmount.value()}));
auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
if (!BEAST_EXPECT(vaultSleAfter))
return;
Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal;
Number const clawbackNum = clawbackAmount.number();
Number const expectedPost{28LL, -1};
Number const expectedPre{42LL, -1};
if (withFix)
{
BEAST_EXPECT(assetsRecovered <= clawbackNum);
BEAST_EXPECT(assetsRecovered == expectedPost);
}
else
{
BEAST_EXPECT(assetsRecovered > clawbackNum);
BEAST_EXPECT(assetsRecovered == expectedPre);
}
};
{
testcase(
"bug: VaultClawback round-trip overshoot lets issuer recover "
"more than requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultClawback round-trip overshoot is clamped so "
"assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
}
}
// Same root cause as testBugClawbackRoundTripOvershoot on the
// withdraw path. Also bypasses the preclaim canWithdraw check, which
// validates destination limits against the requested amount only.
//
// Repro: assetsTotal=7, sharesTotal=5, request 4:
// pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4.
// post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4.
void
testBugWithdrawRoundTripOvershoot()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, bool withFix) {
Env env{*this, features};
auto const setup = makeRoundTripOvershootVault(env);
if (!BEAST_EXPECT(setup))
return;
auto const requested = setup->usd(4);
env(setup->vault.withdraw(
{.depositor = setup->holder,
.id = setup->vaultKeylet.key,
.amount = requested.value()}));
auto const vaultSleAfter = env.current()->read(setup->vaultKeylet);
if (!BEAST_EXPECT(vaultSleAfter))
return;
Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal);
Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal;
Number const requestedNum = requested.number();
Number const expectedPost{28LL, -1};
Number const expectedPre{42LL, -1};
if (withFix)
{
BEAST_EXPECT(assetsWithdrawn <= requestedNum);
BEAST_EXPECT(assetsWithdrawn == expectedPost);
}
else
{
BEAST_EXPECT(assetsWithdrawn > requestedNum);
BEAST_EXPECT(assetsWithdrawn == expectedPre);
}
};
{
testcase(
"bug: VaultWithdraw round-trip overshoot delivers more than "
"requested (pre-fixCleanup3_4_0)");
runScenario(testableAmendments() - fixCleanup3_4_0, false);
}
{
testcase(
"bug: VaultWithdraw round-trip overshoot is clamped so "
"assetsWithdrawn <= requested (post-fixCleanup3_4_0)");
runScenario(testableAmendments(), true);
}
}
void
testCredentialPinsPseudoAccount()
{
@@ -1101,6 +1339,8 @@ public:
testCredentialPinsPseudoAccount();
testCredentialPinOverflow();
testBug6LimitBypassWithShares();
testBugClawbackRoundTripOvershoot();
testBugWithdrawRoundTripOvershoot();
}
};

View File

@@ -546,13 +546,13 @@ private:
}
{
testcase("Scale withdraw with rounding shares up");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
testcase("Scale withdraw with rounding shares up (truncated post-fixCleanup3_4_0)");
// Pre-fixCleanup3_4_0:
// shares = round(875 * 3.75 / 87.5) = 38
// assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
// Post-fixCleanup3_4_0:
// shares = floor(37.5) = 37
// assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
@@ -561,26 +561,23 @@ private:
.amount = STAmount(d.asset, Number(375, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) ==
STAmount(d.asset, start + Number(38, -1)));
STAmount(d.asset, start + Number(37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(875 - 38, -1)));
STAmount(d.asset, Number(875 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(875 - 38, 0)));
STAmount(d.share, -Number(875 - 37, 0)));
}
{
testcase("Scale withdraw with rounding shares down");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
// Chained state: 838 shares outstanding, 83.8 assets.
// shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
// assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
@@ -589,37 +586,37 @@ private:
.amount = STAmount(d.asset, Number(372, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) ==
STAmount(d.asset, start + Number(37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(837 - 37, -1)));
STAmount(d.asset, Number(838 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(837 - 37, 0)));
STAmount(d.share, -Number(838 - 37, 0)));
}
{
testcase("Scale withdraw tiny amount");
testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0");
// Chained state: 801 shares outstanding, 80.1 assets.
// shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
// Zero shares => tecPRECISION_LOSS. State is unchanged.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.withdraw(
{.depositor = d.depositor,
.id = d.keylet.key,
.amount = STAmount(d.asset, Number(9, -2))});
env(tx);
env(tx, Ter{tecPRECISION_LOSS});
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(800 - 1, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(800 - 1, 0)));
env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
}
{
@@ -738,13 +735,13 @@ private:
}
{
testcase("Scale clawback with rounding shares up");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
testcase("Scale clawback with rounding shares up (truncated post-fixCleanup3_4_0)");
// Pre-fixCleanup3_4_0:
// shares = round(875 * 3.75 / 87.5) = 38
// assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested.
// Post-fixCleanup3_4_0:
// shares = floor(37.5) = 37
// assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -754,24 +751,21 @@ private:
.amount = STAmount(d.asset, Number(375, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(875 - 38, -1)));
STAmount(d.asset, Number(875 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(875 - 38, 0)));
STAmount(d.share, -Number(875 - 37, 0)));
}
{
testcase("Scale clawback with rounding shares down");
// assetsToSharesWithdraw:
// shares = sharesTotal * (assets / assetsTotal)
// shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
// sharesToAssetsWithdraw:
// assets = assetsTotal * (shares / sharesTotal)
// assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
// Chained state: 838 shares outstanding, 83.8 assets.
// shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37
// assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -781,18 +775,21 @@ private:
.amount = STAmount(d.asset, Number(372, -2))});
env(tx);
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(837 - 37, -1)));
STAmount(d.asset, Number(838 - 37, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(837 - 37, 0)));
STAmount(d.share, -Number(838 - 37, 0)));
}
{
testcase("Scale clawback tiny amount");
testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0");
// Chained state: 801 shares outstanding, 80.1 assets.
// shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0
// Zero shares => tecPRECISION_LOSS. State is unchanged.
auto const start = env.balance(d.depositor, d.assets).number();
auto tx = d.vault.clawback(
@@ -800,16 +797,14 @@ private:
.id = d.keylet.key,
.holder = d.depositor,
.amount = STAmount(d.asset, Number(9, -2))});
env(tx);
env(tx, Ter{tecPRECISION_LOSS});
env.close();
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801));
BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.assets) ==
STAmount(d.asset, Number(800 - 1, -1)));
env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1)));
BEAST_EXPECT(
env.balance(d.vaultAccount, d.shares) ==
STAmount(d.share, -Number(800 - 1, 0)));
env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0)));
}
{