From 94bccb3a5a781e342ed761e34236d4215152ab40 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Fri, 7 Aug 2026 17:53:54 -0400 Subject: [PATCH] fix: Fix MPT/DEX Audit/Attackathon reports (Phase 2) (#7537) Signed-off-by: dependabot[bot] Co-authored-by: Sergey Kuznetsov Co-authored-by: Ayaz Salikhov Co-authored-by: Andrzej Budzanowski Co-authored-by: Marek Foss Co-authored-by: Alex Kremer Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Bart Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- include/xrpl/ledger/helpers/EscrowHelpers.h | 25 ++- include/xrpl/protocol/detail/features.macro | 2 +- include/xrpl/tx/paths/AMMLiquidity.h | 10 +- src/libxrpl/tx/paths/AMMLiquidity.cpp | 19 +- src/libxrpl/tx/paths/AMMOffer.cpp | 4 + src/libxrpl/tx/paths/BookStep.cpp | 9 +- .../tx/transactors/check/CheckCash.cpp | 26 ++- src/libxrpl/tx/transactors/dex/AMMDeposit.cpp | 32 ++- .../tx/transactors/dex/AMMWithdraw.cpp | 33 ++- src/test/app/AMMMPT_test.cpp | 156 ++++++++++++++ src/test/app/AMM_test.cpp | 192 ++++++++++++++++-- src/test/app/CheckMPT_test.cpp | 53 +++++ src/test/app/EscrowToken_test.cpp | 68 +++++++ src/test/rpc/Feature_test.cpp | 6 +- 14 files changed, 567 insertions(+), 68 deletions(-) diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 9f54e53769..062443cd92 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -241,10 +243,25 @@ escrowUnlockApplyHelper( auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kParityRate) { - // compute transfer fee, if any - auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.asset(), true); - // compute balance to transfer - finalAmt = amount.value() - xferFee; + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + XRPL_ASSERT( + lockedRate >= kParityRate, + "xrpl::escrowUnlockApplyHelper : lockedRate is at least parity"); + // MPTs are integral, so round the delivered amount down and + // charge any fractional transfer fee to the escrowed amount. + auto const delivered = + mulRatio(amount.mpt(), kParityRate.value, lockedRate.value, false); + finalAmt = STAmount(amount.asset(), delivered.value()); + } + else + { + // compute transfer fee, if any + auto const xferFee = + amount.value() - divideRound(amount, lockedRate, amount.asset(), true); + // compute balance to transfer + finalAmt = amount.value() - xferFee; + } } return unlockEscrowMPT( ctx.view, diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 4f1fac82da..de02fed7d8 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -59,7 +59,6 @@ XRPL_FIX (PreviousTxnID, Supported::Yes, VoteBehavior::DefaultNo XRPL_FIX (XChainRewardRounding, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (EmptyDID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PriceOracle, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FIX (AMMOverflowOffer, Supported::Yes, VoteBehavior::DefaultYes) XRPL_FIX (FillOrKill, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(XChainBridge, Supported::Yes, VoteBehavior::DefaultNo) @@ -100,6 +99,7 @@ XRPL_RETIRE_FIX(1578) XRPL_RETIRE_FIX(1623) XRPL_RETIRE_FIX(1781) XRPL_RETIRE_FIX(AmendmentMajorityCalc) +XRPL_RETIRE_FIX(AMMOverflowOffer) XRPL_RETIRE_FIX(CheckThreading) XRPL_RETIRE_FIX(DisallowIncomingV1) XRPL_RETIRE_FIX(InnerObjTemplate) diff --git a/include/xrpl/tx/paths/AMMLiquidity.h b/include/xrpl/tx/paths/AMMLiquidity.h index 1904445554..b08a0ec2f9 100644 --- a/include/xrpl/tx/paths/AMMLiquidity.h +++ b/include/xrpl/tx/paths/AMMLiquidity.h @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -124,17 +123,12 @@ private: generateFibSeqOffer(TAmounts const& balances) const; /** - * Generate max offer. - * If `fixAMMOverflowOffer` is active, the offer is generated as: + * Generate max offer. The offer is generated as: * takerGets = 99% * balances.out takerPays = swapOut(takerGets). * Return nullopt if takerGets is 0 or takerGets == balances.out. - * - * If `fixAMMOverflowOffer` is not active, the offer is generated as: - * takerPays = max input amount; - * takerGets = swapIn(takerPays). */ [[nodiscard]] std::optional> - maxOffer(TAmounts const& balances, Rules const& rules) const; + maxOffer(TAmounts const& balances) const; }; } // namespace xrpl diff --git a/src/libxrpl/tx/paths/AMMLiquidity.cpp b/src/libxrpl/tx/paths/AMMLiquidity.cpp index 0d1c66ead8..1b38847d7b 100644 --- a/src/libxrpl/tx/paths/AMMLiquidity.cpp +++ b/src/libxrpl/tx/paths/AMMLiquidity.cpp @@ -133,17 +133,8 @@ maxOut(T const& out, Asset const& asset) template std::optional> -AMMLiquidity::maxOffer(TAmounts const& balances, Rules const& rules) const +AMMLiquidity::maxOffer(TAmounts const& balances) const { - if (!rules.enabled(fixAMMOverflowOffer)) - { - return AMMOffer( - *this, - {maxAmount(), swapAssetIn(balances, maxAmount(), tradingFee_)}, - balances, - Quality{balances}); - } - auto const out = maxOut(balances.out, assetOut()); if (out <= TOut{0} || out >= balances.out) return std::nullopt; @@ -206,7 +197,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c // changed in BookStep per either deliver amount limit, or // sendmax, or available output or input funds. Might return // nullopt if the pool is small. - return maxOffer(balances, view.rules()); + return maxOffer(balances); } if (auto const amounts = changeSpotPriceQuality(balances, *clobQuality, tradingFee_, view.rules(), j_)) @@ -215,7 +206,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c } if (view.rules().enabled(fixAMMv1_2)) { - if (auto const maxAMMOffer = maxOffer(balances, view.rules()); + if (auto const maxAMMOffer = maxOffer(balances); maxAMMOffer && Quality{maxAMMOffer->amount()} > *clobQuality) return maxAMMOffer; } @@ -223,10 +214,6 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c catch (std::overflow_error const& e) { JLOG(j_.error()) << "AMMLiquidity::getOffer overflow " << e.what(); - if (!view.rules().enabled(fixAMMOverflowOffer)) - { - return maxOffer(balances, view.rules()); - } return std::nullopt; } diff --git a/src/libxrpl/tx/paths/AMMOffer.cpp b/src/libxrpl/tx/paths/AMMOffer.cpp index 3a7bd8f1df..a4a067c4f0 100644 --- a/src/libxrpl/tx/paths/AMMOffer.cpp +++ b/src/libxrpl/tx/paths/AMMOffer.cpp @@ -134,11 +134,13 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: { if (consumed.in > amounts_.in || consumed.out > amounts_.out) { + // LCOV_EXCL_START JLOG(j.error()) << "AMMOffer::checkInvariant failed: consumed " << to_string(consumed.in) << " " << to_string(consumed.out) << " amounts " << to_string(amounts_.in) << " " << to_string(amounts_.out); return false; + // LCOV_EXCL_STOP } Number const product = balances_.in * balances_.out; @@ -149,6 +151,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: if (newProduct >= product || withinRelativeDistance(product, newProduct, Number{1, -7})) return true; + // LCOV_EXCL_START JLOG(j.error()) << "AMMOffer::checkInvariant failed: balances " << to_string(balances_.in) << " " << to_string(balances_.out) << " new balances " << to_string(newBalances.in) << " " << to_string(newBalances.out) @@ -156,6 +159,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: << (product != Number{0} ? to_string((product - newProduct) / product) : "undefined"); return false; + // LCOV_EXCL_STOP } template class AMMOffer; diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 71902ce8b9..e7c2e9ee29 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -865,12 +865,9 @@ BookStep::consumeOffer( { if (!offer.checkInvariant(ofrAmt, j_)) { - // purposely written as separate if statements so we get logging even - // when the amendment isn't active. - if (sb.rules().enabled(fixAMMOverflowOffer)) - { - Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed."); - } + // LCOV_EXCL_START + Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed."); + // LCOV_EXCL_STOP } // The offer owner gets the ofrAmt. The difference between ofrAmt and diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index a8c989f4df..e4d8f192c0 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -19,8 +19,9 @@ #include #include #include +#include #include -#include +#include #include #include #include @@ -376,18 +377,29 @@ CheckCash::doApply() else { // Note that for DeliverMin we don't know exactly how much - // currency we want flow to deliver. We can't ask for the - // maximum possible currency because there might be a gateway - // transfer rate to account for. Since the transfer rate cannot - // exceed 200%, we use 1/2 maxValue as our limit. + // currency we want flow to deliver. For IOUs, use a value + // higher than any real delivery as the request. MPTs are + // bounded integral amounts, so use the maximum output the check + // can actually deliver without exceeding SendMax. auto const maxDeliverMin = [&]() { return optDeliverMin->asset().visit( [&](Issue const&) { return STAmount( optDeliverMin->asset(), STAmount::kMaxValue / 2, STAmount::kMaxOffset); }, - [&](MPTIssue const&) { - return STAmount(optDeliverMin->asset(), kMaxMpTokenAmount / 2); + [&](MPTIssue const& issue) { + MPTAmount maxDeliver = sendMax.mpt(); + auto const& issuer = issue.getIssuer(); + if (srcId != issuer && accountID_ != issuer) + { + auto const rate = transferRate(psb, issue.getMptID()); + // Request at most floor(SendMax / rate). The endpoint reverse pass + // will quote ceil(output * rate), so this keeps the input + // representable and within SendMax. + maxDeliver = + mulRatio(maxDeliver, QUALITY_ONE, rate.value, /*roundUp*/ false); + } + return STAmount(maxDeliver, issue); }); }; STAmount const flowDeliver{ diff --git a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp index 0d1798babc..64d6d70e67 100644 --- a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include namespace xrpl { @@ -437,11 +438,10 @@ AMMDeposit::applyGuts(Sandbox& sb) auto const subTxType = ctx_.tx.getFlags() & tfDepositSubTx; - auto const [result, newLPTokenBalance] = [&, - &amountBalance = amountBalance, - &amount2Balance = amount2Balance, - &lptAMMBalance = - lptAMMBalance]() -> std::pair { + auto dispatchToDeposit = [&, + &amountBalance = amountBalance, + &amount2Balance = amount2Balance, + &lptAMMBalance = lptAMMBalance]() -> std::pair { if (subTxType & tfTwoAsset) { return equalDepositLimit( @@ -493,6 +493,28 @@ AMMDeposit::applyGuts(Sandbox& sb) JLOG(j_.error()) << "AMM Deposit: invalid options."; return std::make_pair(tecINTERNAL, STAmount{}); // LCOV_EXCL_STOP + }; + + auto const [result, newLPTokenBalance] = [&]() -> std::pair { + try + { + return dispatchToDeposit(); + } + catch (std::runtime_error const& e) + { + REACHABLE("xrpl::AMMDeposit::applyGuts : deposit amount out of range reached"); + // A deposit whose solved amount exceeds the integral asset's range + // throws while converting to STAmount: past int64max + // Number::operator rep() throws std::overflow_error; above the asset + // maximum STAmount::canonicalize throws std::runtime_error. Fail + // cleanly with a tec rather than letting it escape doApply as + // tefEXCEPTION. Any other exception is left to propagate. + // Gated by fixCleanup3_4_0 to preserve the legacy result pre-amendment. + if (!sb.rules().enabled(fixCleanup3_4_0)) + throw; // LCOV_EXCL_LINE - preserve legacy tefEXCEPTION + JLOG(j_.error()) << "AMMDeposit: deposit amount out of range " << e.what(); + return std::make_pair(tecAMM_FAILED, STAmount{}); + } }(); if (isTesSuccess(result)) diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index 2baa7edfb4..5294dd0c7f 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -374,11 +375,10 @@ AMMWithdraw::applyGuts(Sandbox& sb) auto const [amountBalance, amount2Balance, lptAMMBalance] = *expected; auto const subTxType = ctx_.tx.getFlags() & tfWithdrawSubTx; - auto const [result, newLPTokenBalance] = [&, - &amountBalance = amountBalance, - &amount2Balance = amount2Balance, - &lptAMMBalance = - lptAMMBalance]() -> std::pair { + auto dispatchToWithdraw = [&, + &amountBalance = amountBalance, + &amount2Balance = amount2Balance, + &lptAMMBalance = lptAMMBalance]() -> std::pair { if (subTxType & tfTwoAsset) { return equalWithdrawLimit( @@ -432,6 +432,29 @@ AMMWithdraw::applyGuts(Sandbox& sb) JLOG(j_.error()) << "AMM Withdraw: invalid options."; return std::make_pair(tecINTERNAL, STAmount{}); // LCOV_EXCL_STOP + }; + + auto const [result, newLPTokenBalance] = [&]() -> std::pair { + try + { + return dispatchToWithdraw(); + } + catch (std::runtime_error const& e) + { + // Defense in-depth for amount overflow/out-of-range: the withdrawal + // counterpart of the AMMDeposit guard. Unlike deposit, no known + // withdraw path can throw here - preclaim bounds the requested + // amounts by the pool balances, and the only historical throw + // (denom == 0 in singleWithdrawEPrice) is guarded under + // fixCleanup3_3_0. Gated by fixCleanup3_4_0 to preserve the + // legacy tefEXCEPTION pre-amendment. + if (!sb.rules().enabled(fixCleanup3_4_0)) + throw; + // LCOV_EXCL_START + JLOG(j_.error()) << "AMMWithdraw: amount out of range " << e.what(); + return std::make_pair(tecAMM_FAILED, STAmount{}); + // LCOV_EXCL_STOP + } }(); if (!isTesSuccess(result)) diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index bf0bc5c7d7..7078ea6769 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -7142,6 +7142,159 @@ private: } } + void + testDepositIntegralOverflowMPT(FeatureBitset features) + { + testcase("Deposit integral overflow (MPT)"); + + using namespace jtx; + + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const err = features[fixCleanup3_4_0] ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION); + + // MPT counterpart of AMM_test::testDepositIntegralOverflow. A two-asset + // deposit with a huge Amount against a tiny pool leg makes + // frac = Amount / balance enormous, so the computed deposit for the + // other (integral) leg exceeds Number's int64 range (kMaxRep ~= + // 9.22e18) and the conversion to an integral STAmount throws out of + // doApply - which applySteps would surface as tefEXCEPTION. + // + // The default amendments include fixCleanup3_4_0, under which applyGuts + // guards the overflow and fails cleanly with tecAMM_FAILED. This + // verifies the guarded path: no overflow escapes. + + // XRP/MPT - the exact pool the report (Antithesis) calls out. A tiny + // mpt(1) balance and a huge MPT Amount drive frac; the XRP leg is what + // overflows: XRP(10) is 1e7 drops, so getRoundedAsset(XRP, frac) is + // 1e7 * 1e13 = 1e20 drops, well past kMaxRep. + { + // The deposit intentionally overflows, which logs at error. + // Disable the log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + if (!features[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); + env.fund(XRP(30'000), gw_, alice_); + env.close(); + + // kMptDexFlags (CanTrade | CanTransfer), which AMMs require, is + // the default. alice must hold enough MPT to fund the pool and the + // oversized deposit. + MPT const mpt = MPTTester( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 100'000'000'000'000, // 1e14 + .maxAmt = 1'000'000'000'000'000}); // 1e15 + env.close(); + + AMM amm(env, alice_, XRP(10), mpt(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = mpt(10'000'000'000'000), // 1e13 + .asset2In = XRP(1), + .err = err}); + } + + // IOU/MPT - the MPT leg is the one that overflows. A classic IOU + // trustline drives frac (huge USD Amount vs USD(1) balance); the + // MPT-side deposit is then mptBalance * frac = 10'000 * 1e16 = 1e20. + { + // The deposit intentionally overflows, which logs at error. + // Disable the log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + MPT const mpt = + MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000}); + env.close(); + + AMM amm(env, alice_, mpt(10'000), USD(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = STAmount{USD, 1, 16}, + .asset2In = mpt(1), + .err = err}); + } + } + + void + testWithdrawIntegralNoOverflowMPT() + { + testcase("Withdraw integral no overflow (MPT)"); + + using namespace jtx; + + // MPT counterpart of AMM_test::testWithdrawIntegralNoOverflow and the + // sibling of testDepositIntegralOverflowMPT. AMMWithdraw:: + // equalWithdrawLimit has the same getRoundedAsset(integralBalance, + // frac) structure as the deposit path and is likewise not wrapped in a + // try/catch. It is safe only because withdraw preclaim (checkAmount) + // rejects a requested Amount greater than the pool balance with + // tecAMM_BALANCE *before* the math runs, so frac = Amount / balance + // stays <= 1 and the Number -> integral STAmount conversion cannot + // overflow. Deposit has no such bound, which is why only the deposit + // path was exposed. + // + // These mirror the deposit repros: the same oversized two-asset + // request is rejected cleanly. If the preclaim bound is ever weakened, + // equalWithdrawLimit would be reached with a huge frac and + // Number::operator rep() would escape as tefEXCEPTION, failing this. + + // XRP/MPT - the pool the report calls out. Requesting far more of the + // tiny MPT leg than the pool holds is rejected before the math. + { + Env env(*this); + env.fund(XRP(30'000), gw_, alice_); + env.close(); + + MPT const mpt = MPTTester( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 100'000'000'000'000, // 1e14 + .maxAmt = 1'000'000'000'000'000}); // 1e15 + env.close(); + + // alice holds all LPTokens of a tiny XRP/MPT pool. + AMM amm(env, alice_, XRP(10), mpt(1)); + amm.withdraw( + WithdrawArg{ + .account = alice_, + .asset1Out = mpt(10'000'000'000'000), // 1e13 > mpt(1) + .asset2Out = XRP(1), + .err = Ter(tecAMM_BALANCE)}); + } + + // IOU/MPT - requesting far more of the tiny IOU leg than the pool + // holds is likewise rejected. + { + Env env(*this); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + MPT const mpt = + MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000}); + env.close(); + + AMM amm(env, alice_, mpt(10'000), USD(1)); + amm.withdraw( + WithdrawArg{ + .account = alice_, + .asset1Out = STAmount{USD, 1, 16}, // > USD(1) + .asset2Out = mpt(1), + .err = Ter(tecAMM_BALANCE)}); + } + } + void run() override { @@ -7178,6 +7331,9 @@ private: testAMMDepositWithFrozenAssets(); testAMMWithVaultShares(); testAutoDelete(); + testDepositIntegralOverflowMPT(all); + testDepositIntegralOverflowMPT(all - fixCleanup3_4_0); + testWithdrawIntegralNoOverflowMPT(); } }; diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index f19743026c..8f8079c34a 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -2292,8 +2293,9 @@ private: // ePrice = lptAMMBalance(100) * f(0.001) / amountBalance(100) = 0.001 testAMM( [&](AMM& ammAlice, Env& env) { - auto const err = - env.enabled(fixCleanup3_3_0) ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION); + auto const err = env.enabled(fixCleanup3_3_0) || env.enabled(fixCleanup3_4_0) + ? Ter(tecAMM_FAILED) + : Ter(tefEXCEPTION); ammAlice.withdraw( WithdrawArg{ .account = alice_, @@ -2304,7 +2306,7 @@ private: {{USD(100), EUR(100)}}, 1000, std::nullopt, - {all - fixCleanup3_3_0, all}); + {all - fixCleanup3_3_0 - fixCleanup3_4_0, all - fixCleanup3_4_0, all}); } void @@ -6024,7 +6026,7 @@ private: void // NOLINTNEXTLINE(readability-convert-member-functions-to-static) - testFixOverflowOffer(FeatureBitset featuresInitial) + testOverflowOffer(FeatureBitset featuresInitial) { using namespace jtx; using namespace std::chrono; @@ -6259,7 +6261,7 @@ private: }) { testcase(input.testCase); - for (auto const& features : {all - fixAMMOverflowOffer - fixAMMv1_1 - fixAMMv1_3, all}) + for (auto const& features : {all - fixAMMv1_1 - fixAMMv1_3, all}) { Env env(*this, features, std::make_unique(&logs)); @@ -6308,11 +6310,6 @@ private: return input.lpTokenBalanceAlt.value_or(input.lpTokenBalance); }(); - if (!features[fixAMMOverflowOffer]) - { - BEAST_EXPECT(amm.expectBalances(failUsdGH, failUsdBIT, lpTokenBalance)); - } - else { BEAST_EXPECT(amm.expectBalances(goodUsdGH, goodUsdBIT, lpTokenBalance)); @@ -7210,6 +7207,172 @@ private: } } + void + testDepositIntegralOverflow() + { + testcase("Deposit integral overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Found by Antithesis: two-asset deposit with a huge Amount against a + // tiny pool leg makes frac = Amount/amountBalance enormous, so the + // computed XRP-side deposit exceeds the integral asset's range and the + // conversion to an STAmount throws out of doApply. + // + // applyGuts catches std::runtime_error around the deposit math, which + // covers both ways the conversion can throw: + // - value beyond int64 range: Number::operator rep() throws + // std::overflow_error (a std::runtime_error); and + // - value within int64 but above the asset maximum (kMaxNativeN): + // STAmount::canonicalize throws std::runtime_error. + // XRP(10) is 1e7 drops, so the computed XRP leg is 1e7 * frac: + // asset1In 1e15 => frac ~1e15 => ~1e22 drops, past int64max; and + // asset1In 1e11 => frac ~1e11 => ~1e18 drops, in [kMaxNativeN=1e17, + // int64max) - the canonicalize band, which would otherwise escape. + // + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const test = [this](FeatureBitset features, STAmount const& asset1In, TER expected) { + // These deposits intentionally trigger the overflow, which logs + // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the + // log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + AMM amm(env, gw_, XRP(10), USD(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = asset1In, + .asset2In = XRP(1), + .err = Ter(expected)}); + }; + + // int64-range band (overflow_error): legacy escapes as tefEXCEPTION, + // fixed returns a tec. + test(all - fixCleanup3_4_0, STAmount{USD, 1, 15}, tefEXCEPTION); + test(all, STAmount{USD, 1, 15}, tecAMM_FAILED); + // canonicalize band (runtime_error): same behavior. Regression guard + // for the band a plain overflow_error catch would miss. + test(all - fixCleanup3_4_0, STAmount{USD, 1, 11}, tefEXCEPTION); + test(all, STAmount{USD, 1, 11}, tecAMM_FAILED); + } + + void + testDepositEPriceIntegralOverflow() + { + testcase("Deposit EPrice integral overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Found by Antithesis: a one-sided tfLimitLPToken deposit (Amount and + // EPrice) with Amount = 0 and a large EPrice makes the solved pool-side + // deposit enormous, so it exceeds the integral asset's range and the + // conversion to an STAmount throws out of doApply. This is the + // singleDepositEPrice sibling of testDepositIntegralOverflow. + // + // applyGuts catches std::runtime_error around the deposit math, which + // covers both ways the conversion can throw: + // - value beyond int64 range: Number::operator rep() throws + // std::overflow_error (a std::runtime_error); and + // - value within int64 but above the asset maximum (kMaxNativeN): + // STAmount::canonicalize throws std::runtime_error. + // + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const test = [this](FeatureBitset features, STAmount const& ePrice, TER expected) { + // These deposits intentionally trigger the overflow, which logs + // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the + // log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + AMM amm(env, gw_, XRP(10), USD(1)); + // Amount = 0 (XRP), EPrice large => tfLimitLPToken. The solved XRP + // leg blows past the integral range. + amm.deposit( + DepositArg{ + .account = alice_, .asset1In = XRP(0), .maxEP = ePrice, .err = Ter(expected)}); + }; + + // For this XRP(10)/USD(1) pool the LPToken balance is + // sqrt(1e7 drops * 1) = 3162, so T^2/B = 1e7/1e7 = 1 and the solved + // XRP-side deposit is ~EPrice^2 drops. + // + // int64-range band (overflow_error): legacy escapes as tefEXCEPTION, + // fixed returns a tec. EPrice ~1e17 drops => solved deposit ~1e34 drops, + // past int64max, so Number::operator rep() throws. + auto const bigEP = STAmount{XRPAmount{99'999'999'999'999'999}}; + test(all - fixCleanup3_4_0, bigEP, tefEXCEPTION); + test(all, bigEP, tecAMM_FAILED); + // canonicalize band (runtime_error): same behavior. Regression guard + // for the band a plain overflow_error catch would miss. EPrice 1e9 drops + // => solved deposit ~1e18 drops, in [kMaxNativeN=1e17, int64max), so + // STAmount::canonicalize throws. + auto const midEP = STAmount{XRPAmount{1'000'000'000}}; + test(all - fixCleanup3_4_0, midEP, tefEXCEPTION); + test(all, midEP, tecAMM_FAILED); + } + + void + testWithdrawIntegralNoOverflow() + { + testcase("Withdraw integral no overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Regression guard for the sibling of testDepositIntegralOverflow. + // AMMWithdraw::equalWithdrawLimit has the same + // getRoundedAsset(integralBalance, frac) structure as the deposit + // path and is likewise not wrapped in a try/catch. It is safe only + // because withdraw preclaim (checkAmount) rejects a requested Amount + // greater than the pool balance with tecAMM_BALANCE *before* the math + // runs, so frac = Amount / balance stays <= 1 and the Number -> + // integral STAmount conversion cannot overflow. Deposit has no such + // bound (depositing more than the pool holds is legal), which is why + // only the deposit path was exposed. + // + // This asserts the withdrawal analog of the deposit repro fails cleanly + // with a tec. If the preclaim bound is ever weakened, equalWithdrawLimit + // would be reached with a huge frac and Number::operator rep() would + // escape as tefEXCEPTION, failing this test. + auto const test = [this](FeatureBitset features) { + Env env(*this, features); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + // gw holds all LPTokens of a tiny XRP/USD pool. + AMM amm(env, gw_, XRP(10), USD(1)); + + // Two-asset limit withdraw (tfTwoAsset) requesting far more of the + // tiny USD leg than the pool holds - the mirror of the deposit + // repro. Rejected upstream, so no overflow is possible. + amm.withdraw( + WithdrawArg{ + .account = gw_, + .asset1Out = STAmount{USD, 1, 15}, + .asset2Out = XRP(1), + .err = Ter(tecAMM_BALANCE)}); + }; + + // Bound holds regardless of the deposit-side fix amendment. + test(all - featureMPTokensV2); + test(all); + } + void run() override { @@ -7251,9 +7414,9 @@ private: testSelection(all - fixAMMv1_1 - fixAMMv1_3); testFixDefaultInnerObj(); testMalformed(); - testFixOverflowOffer(all); - testFixOverflowOffer(all - fixAMMv1_3); - testFixOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); + testOverflowOffer(all); + testOverflowOffer(all - fixAMMv1_3); + testOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); testSwapRounding(); testFixChangeSpotPriceQuality(all); testFixChangeSpotPriceQuality(all - fixAMMv1_1 - fixAMMv1_3); @@ -7282,6 +7445,9 @@ private: testFailedPseudoAccount(); testStaleAuthAccountsAfterReinit(all); testStaleAuthAccountsAfterReinit(all - fixCleanup3_2_0); + testDepositIntegralOverflow(); + testDepositEPriceIntegralOverflow(); + testWithdrawIntegralNoOverflow(); } }; diff --git a/src/test/app/CheckMPT_test.cpp b/src/test/app/CheckMPT_test.cpp index 66cc582201..ffc9fb21b4 100644 --- a/src/test/app/CheckMPT_test.cpp +++ b/src/test/app/CheckMPT_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -653,6 +654,32 @@ class CheckMPT_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); BEAST_EXPECT(ownerCount(env, bob) == 1); } + + { + Env env{*this, features}; + + env.fund(XRP(1'000), gw, alice, bob); + + // MPT DeliverMin should not be capped at half of the legal range. + std::uint64_t constexpr deliverMin = (kMaxMpTokenAmount / 2) + 1; + MPT const usd = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, alice, usd(deliverMin))); + env.close(); + + uint256 const chkId{getCheckIndex(alice, env.seq(alice))}; + env(check::create(alice, bob, usd(deliverMin))); + env.close(); + + env(check::cash(bob, chkId, check::DeliverMin(usd(deliverMin)))); + verifyDeliveredAmount(env, usd(deliverMin)); + env.require(Balance(alice, usd(0))); + env.require(Balance(bob, usd(deliverMin))); + BEAST_EXPECT(checksOnAccount(env, alice).empty()); + BEAST_EXPECT(checksOnAccount(env, bob).empty()); + } + { // Examine the effects of the asfRequireAuth flag. Env env(*this, features); @@ -807,6 +834,32 @@ class CheckMPT_test : public beast::unit_test::Suite env.require(Balance(bob, usd(0 + 100))); BEAST_EXPECT(checksOnAccount(env, alice).empty()); BEAST_EXPECT(checksOnAccount(env, bob).empty()); + + // With the maximum transfer fee, this is the largest output whose + // fee-adjusted debit is still within SendMax. + std::uint64_t constexpr maxDeliver = (kMaxMpTokenAmount / 3) * 2; + MPT const eur = MPTTester( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = kMaxTransferFee, + .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, alice, eur(kMaxMpTokenAmount))); + env.close(); + + uint256 const chkIdMax{getCheckIndex(alice, env.seq(alice))}; + env(check::create(alice, bob, eur(kMaxMpTokenAmount))); + env.close(); + + // The DeliverMin cap must divide SendMax by the rate before flow() + // computes the fee-adjusted input. + env(check::cash(bob, chkIdMax, check::DeliverMin(eur(maxDeliver)))); + verifyDeliveredAmount(env, eur(maxDeliver)); + env.require(Balance(alice, eur(1))); + env.require(Balance(bob, eur(maxDeliver))); + BEAST_EXPECT(checksOnAccount(env, alice).empty()); + BEAST_EXPECT(checksOnAccount(env, bob).empty()); } void diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index d0decaf497..7e7509c3b7 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -3683,6 +3683,72 @@ struct EscrowToken_test : public beast::unit_test::Suite } } + void + testMPTSplitEscrowTransferFee(FeatureBitset features) + { + using namespace test::jtx; + using namespace std::literals; + + bool const withCleanup340 = features[fixCleanup3_4_0]; + testcase( + std::string("MPT Split Escrow Transfer Fee ") + + (withCleanup340 ? "with Cleanup340" : "without Cleanup340")); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(1'000), alice, bob, gw); + env.close(); + + MPTTester const mpt({ + .env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer, + }); + env(pay(gw, alice, mpt(10'000))); + env.close(); + + static constexpr int escrowCount = 10; + static constexpr int splitAmount = 10; + static constexpr int totalLocked = escrowCount * splitAmount; + std::array seqs{}; + for (auto& seq : seqs) + { + seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(splitAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150)); + env.close(); + } + + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(0)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == totalLocked); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == totalLocked); + + for (auto const seq : seqs) + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150)); + env.close(); + } + + auto const feeBurned = withCleanup340 ? escrowCount : 0; + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(totalLocked - feeBurned)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000 + feeBurned)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + void testMPTRequireAuth(FeatureBitset features) { @@ -4001,6 +4067,8 @@ public: testMPTWithFeats(feats); testMPTWithFeats(feats - fixTokenEscrowV1); } + testMPTSplitEscrowTransferFee(all - fixCleanup3_4_0); + testMPTSplitEscrowTransferFee(all); } }; diff --git a/src/test/rpc/Feature_test.cpp b/src/test/rpc/Feature_test.cpp index a36e51cb6f..1e2504bf7f 100644 --- a/src/test/rpc/Feature_test.cpp +++ b/src/test/rpc/Feature_test.cpp @@ -187,13 +187,13 @@ class Feature_test : public beast::unit_test::Suite using namespace test::jtx; Env env{*this}; - std::string const name = "fixAMMOverflowOffer"; + std::string const name = "fixCleanup3_1_3"; auto jrr = env.rpc("feature", name)[jss::result]; BEAST_EXPECTS(jrr[jss::status] == jss::success, "status"); jrr.removeMember(jss::status); BEAST_EXPECT(jrr.size() == 1); auto const expected = to_string(sha512Half(Slice(name.data(), name.size()))); - char const sha[] = "12523DF04B553A0B1AD74F42DDB741DE8DC06A03FC089A0EF197E2A87F1D8107"; + char const sha[] = "303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC"; BEAST_EXPECT(expected == sha); BEAST_EXPECT(jrr.isMember(expected)); auto feature = *(jrr.begin()); @@ -475,7 +475,7 @@ class Feature_test : public beast::unit_test::Suite using namespace test::jtx; Env env{*this, FeatureBitset{featurePriceOracle}}; - static constexpr char const* kFeatureName = "fixAMMOverflowOffer"; + static constexpr char const* kFeatureName = "fixCleanup3_1_3"; auto jrr = env.rpc("feature", kFeatureName)[jss::result]; if (!BEAST_EXPECTS(jrr[jss::status] == jss::success, "status"))