Compare commits

..

9 Commits

Author SHA1 Message Date
Vito
0184e1eae4 Merge remote-tracking branch 'origin/develop' into tapanito/vault-clawback-bug 2026-08-25 11:19:49 +02:00
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
11 changed files with 340 additions and 89 deletions

View File

@@ -33,14 +33,12 @@ jobs:
strategy:
fail-fast: false
matrix:
# Newest of each distro: these images only wrap pre-built binaries, so
# they set no floor for consumers. build_pkg.py pins the RPM dist tag.
distro:
- name: debian
base_image: debian:trixie
# AlmaLinux rather than UBI, which does not ship rpm-sign.
base_image: debian:bookworm
# AlmaLinux rather than UBI9, which does not ship rpm-sign.
- name: rhel
base_image: almalinux:10
base_image: almalinux:9
uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a
with:
image_name: xrpld/packaging-${{ matrix.distro.name }}

View File

@@ -103,7 +103,7 @@ namespace boost {
template <>
struct hash<::beast::ip::Address>
{
hash() = default;
explicit hash() = default;
std::size_t
operator()(::beast::ip::Address const& addr) const

View File

@@ -133,7 +133,7 @@ private:
using id_hash_type = boost::base_from_member<std::hash<xrpl::MPTID>, 0>;
public:
hash() = default;
explicit hash() = default;
using value_type = std::size_t;
using argument_type = xrpl::MPTIssue;
@@ -160,7 +160,7 @@ private:
mptissue_hasher mMptissueHasher_;
public:
hash() = default;
explicit hash() = default;
value_type
operator()(argument_type const& asset) const
@@ -227,7 +227,7 @@ struct hash<xrpl::Issue> : std::hash<xrpl::Issue>
template <>
struct hash<xrpl::MPTIssue> : std::hash<xrpl::MPTIssue>
{
hash() = default;
explicit hash() = default;
using Base = std::hash<xrpl::MPTIssue>;
};
@@ -235,7 +235,7 @@ struct hash<xrpl::MPTIssue> : std::hash<xrpl::MPTIssue>
template <>
struct hash<xrpl::Asset> : std::hash<xrpl::Asset>
{
hash() = default;
explicit hash() = default;
using Base = std::hash<xrpl::Asset>;
};

View File

@@ -151,7 +151,7 @@ namespace std {
template <>
struct hash<xrpl::MPTID> : xrpl::MPTID::hasher
{
hash() = default;
explicit hash() = default;
};
} // namespace std

View File

@@ -114,8 +114,8 @@ Development tooling:
Rust toolchain:
✅ cargo
cargo 1.97.1 (c980f4866 2026-06-30)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo
✅ cargo-audit
cargo-audit-audit 0.22.1
/nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit
@@ -126,17 +126,17 @@ Rust toolchain:
cargo-nextest 0.9.137
/nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest
✅ clippy-driver
clippy 0.1.97 (8bab26f4f6 2026-07-14)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver
clippy 0.1.95 (59807616e1 2026-04-14)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver
✅ rust-analyzer
rust-analyzer 1.97.1 (8bab26f4 2026-07-14)
/nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer
rust-analyzer 1.95.0 (59807616 2026-04-14)
/nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer
✅ rustc
rustc 1.97.1 (8bab26f4f 2026-07-14)
/nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc
rustc 1.95.0 (59807616e 2026-04-14)
/nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc
✅ rustfmt
rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14)
/nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt
rustfmt 1.9.0-stable (59807616e1 2026-04-14)
/nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt
Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set).

View File

@@ -28,31 +28,31 @@ esac
# - debhelper and dpkg-dev build the DEB
# - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config
# supplying the systemd and find-debuginfo macros the spec uses
# - rpm-sign and gnupg2 sign the built RPM
# - python3 runs the packaging scripts
# - git gives build_pkg.py the commit timestamp it stamps files with
# - ca-certificates lets git and the packaging scripts verify TLS
# - rpm-sign signs the built RPM
# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from;
# without one the timestamp falls back to the wall clock
# - curl uploads the finished packages in publish_pkg.sh
# - ca-certificates lets curl and git verify TLS
function install() {
case "${ID}" in
debian | ubuntu)
apt-get update -y
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
debhelper \
debhelper-compat \
dpkg-dev \
git \
python3
git
;;
rhel | centos | rocky | almalinux)
dnf install -y --setopt=install_weak_deps=False \
curl-minimal \
git \
gnupg2 \
python3 \
redhat-rpm-config \
rpm-build \
rpm-sign \
redhat-rpm-config \
systemd-rpm-macros
;;
esac

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.97.1"
components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"]
channel = "1.95"
components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"]
profile = "minimal"

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)));
}
{