Compare commits

...
Author SHA1 Message Date
Gregory Tsipenyuk 92c1761d27 fix: Enable the large Number mantissa with MPTokensV2
MPT arithmetic under MPTokensV2 uses Number, and a 19-digit MPT amount
doesn't fit the small 16-digit mantissa that transactions use unless
SingleAssetVault or LendingProtocol is enabled. Add MPTokensV2 to the
amendments that enable the large mantissa, so MPT math never runs at
the small scale regardless of activation order.

The IOU AMM tests keep the small mantissa, as they do for
SingleAssetVault and LendingProtocol. The MPT AMM tests now run with
the large mantissa, and their expected values are updated.
2026-09-29 12:58:37 -04:00
Gregory Tsipenyuk 7326fbd144 fix: Use Number arithmetic in divide() for MPT operands
The legacy divide() assumes 16-digit mantissas, but an MPT mantissa is
the raw 63-bit value and is never scaled down. With an MPT numerator the
quotient can exceed 2^64, so muldiv() throws and getRate() returns 0,
and the offer can't be placed. With an MPT denominator the quotient is
shorter, and the +5 rounding nudge lands in a significant digit.

Under MPTokensV2, use Number arithmetic in divide() when any operand is
an MPT, as mulRound/divRound do.
2026-09-29 11:22:19 -04:00
Gregory Tsipenyuk 56d6ff3067 fix: Keep divide() quotients within the signed mantissa range
An integral numerator is not scaled down to 16 digits, so divide() can
produce a quotient between 2^63 and 2^64. The STAmount constructor then
reads it as a negative mantissa, and getRate() drops the sign, so the
recorded offer quality doesn't match the offer's amounts.

Under MPTokensV2, drop a digit before constructing the result.
2026-09-28 19:07:59 -04:00
Gregory Tsipenyuk 6c07421e4f fix: Remove offers whose fee-clipped input rounds to zero
When an offer owner's funds cover TakerGets but not the transfer fee,
BookStep reduces the offer to what the owner can pay including the
fee. For small integral TakerPays, the reduced TakerPays can round
down to zero while TakerGets stays positive. OfferStream's dust check
doesn't account for the fee, so the offer is not removed.

Remove the offer when its fee-clipped input is zero, next to the
existing zero-output check under MPTokensV2.
2026-09-28 14:39:26 -04:00
Gregory Tsipenyuk 05ffa06c21 Merge branch 'develop' into gregtatcam/mpt/dex-fix-offers 2026-09-17 09:47:53 -04:00
Gregory Tsipenyuk bd7fda4853 fix: Remove MPT offers whose fee-adjusted output rounds to zero
OfferStream's dust check is fee-blind, so a 1-MPT (or 1-capacity)
sell with a transfer fee still looks funded. BookStep then floors
funds/rate to 0. Consuming that slice is a no-op that leaves the
offer at the tip, dries the strand, and hides worse-quality
liquidity behind it. Under featureMPTokensV2, remove the offer
instead.

issuerSelfDebitHookMPT no longer asserts amount > 0: other clips
(for example MaximumAmount via limitStepIn) can still consume a
zero slice of a funded remainder.
2026-09-15 12:03:52 -04:00
Gregory Tsipenyuk 844c250aa2 fix: Use Number arithmetic for any MPT operand in mulRound/divRound
The legacy rounded multiply/divide assume 16-digit mantissas. An MPT
mantissa is the raw 63-bit balance, so a large MPT operand with an XRP
or IOU result overflowed uint64 and threw: OfferCreate::flowCross
failed with tecINTERNAL when recomputing a partially crossed
remainder, and BookStep removed large MPT offers it could have
partially filled. Take the MPTokensV2 Number path whenever any operand
is MPT, not only when the result is. Outside a transaction default to
it too, since an MPT operand can only reach here through MPTokensV2
features; this keeps pathfinding on the transaction's arithmetic.
2026-09-11 09:17:25 +01:00
Gregory Tsipenyuk 15a57a917a fix: Limit MPT offer input to MaximumAmount in BookStep
OfferCreate doesn't compare TakerPays with the MPT's MaximumAmount,
so a bid above the cap rests. Taking it has the issuer send TakerPays
to the owner, and isMPTOverflow() rejects a single send above the
cap, so the strand dried instead of being limited by upstream supply
and the bid stayed on the book. Limit the offer's input to the cap
(read once per BookStep) and keep the funded remainder, as for a
taker-limited offer. The issuer's own bids are unaffected: a send
to itself is a no-op.
2026-09-11 09:17:25 +01:00
Gregory Tsipenyuk f64d034292 fix: Handle MPT offers whose fee gross-up exceeds the maximum
TakerGets times the transfer rate can exceed the 63-bit MPT max
and flowCross() failed with tecINTERNAL; cap sendMax at the
balance instead. A bid whose TakerPays times the rate exceeds it
can never be taken (BookStep would remove it), so cross what is
possible and don't place the remainder (tecKILLED if nothing
crossed), as for an unrepresentable quality.
2026-09-11 09:17:25 +01:00
19 changed files with 2060 additions and 291 deletions

No files matched your search

+25 -4
View File
@@ -312,12 +312,22 @@ checkCreateMPT(
//
//------------------------------------------------------------------------------
// MaximumAmount doesn't exceed 2**63-1
/**
* MaximumAmount doesn't exceed 2**63-1
*/
std::int64_t
maxMPTAmount(SLE const& sleIssuance);
// OutstandingAmount may overflow and available amount might be negative.
// But available amount is always <= |MaximumAmount - OutstandingAmount|.
/**
* std::nullopt if the issuance doesn't exist.
*/
std::optional<std::int64_t>
maxMPTAmount(ReadView const& view, MPTID const& mptID);
/**
* OutstandingAmount may overflow and available amount might be negative.
* But available amount is always <= |MaximumAmount - OutstandingAmount|.
*/
std::int64_t
availableMPTAmount(SLE const& sleIssuance);
@@ -331,7 +341,12 @@ availableMPTAmount(ReadView const& view, MPTID const& mptID);
* directSendNoFee transactions that bypass the payment engine.
* 2. **accountSend & Payment Engine (Overflow: Yes):** A temporary overflow
* check when `OutstandingAmount > UINT64_MAX`. This higher threshold is used
* for `accountSend` and payments processed via the payment engine.
* for `accountSend` and payments processed via the payment engine, whose
* reverse pass may issue before it redeems.
*
* In both cases a single `sendAmount > MaximumAmount` is rejected. Such a send
* can never settle: redemptions within the transaction are bounded by the
* outstanding amount, so the final OutstandingAmount would exceed the cap.
*/
bool
isMPTOverflow(
@@ -353,6 +368,12 @@ issuerFundsToSelfIssue(ReadView const& view, MPTIssue const& issue);
/**
* Facilitate tracking of MPT sold by an issuer owning MPT sell offer.
* See ApplyView::issuerSelfDebitHookMPT().
*
* `amount` may be 0. BookStep removes an offer whose fee-adjusted funded
* output is 0, but other clips (for example MaximumAmount via limitStepIn)
* can still consume a zero slice of a funded remainder. The hook must
* accept 0: first call still snapshots origBalance; a zero debit adds
* nothing to selfDebit.
*/
void
issuerSelfDebitHookMPT(ApplyView& view, MPTIssue const& issue, std::uint64_t amount);
-2
View File
@@ -435,8 +435,6 @@ PaymentSandbox::issuerSelfDebitHookMPT(
std::uint64_t amount,
std::int64_t origBalance)
{
XRPL_ASSERT(amount > 0, "PaymentSandbox::issuerSelfDebitHookMPT: amount must be > 0");
tab_.issuerSelfDebitMPT(issue, amount, origBalance);
}
@@ -1047,6 +1047,14 @@ maxMPTAmount(SLE const& sleIssuance)
return sleIssuance[~sfMaximumAmount].value_or(kMaxMpTokenAmount);
}
std::optional<std::int64_t>
maxMPTAmount(ReadView const& view, MPTID const& mptID)
{
if (auto const sle = view.read(keylet::mptokenIssuance(mptID)))
return maxMPTAmount(*sle);
return std::nullopt;
}
std::int64_t
availableMPTAmount(SLE const& sleIssuance)
{
+5 -3
View File
@@ -43,8 +43,9 @@ setCurrentTransactionRules(std::optional<Rules> r)
auto const range = [&r]() {
// If any new conditions with new amendments are added to "enableLargeNumbers", those
// amendments must also be added to useRulesGuards.
bool const enableLargeNumbers =
!r || (r->enabled(featureSingleAssetVault) || r->enabled(featureLendingProtocol));
bool const enableLargeNumbers = !r ||
(r->enabled(featureSingleAssetVault) || r->enabled(featureLendingProtocol) ||
r->enabled(featureMPTokensV2));
// If enableLargeNumbers is true, then useRulesGuards must also return true.
// However, the reverse is not true. Other amendments can cause the rules guard to be used,
// even though large numbers are _not_ used.
@@ -84,7 +85,8 @@ useRulesGuards(Rules const& rules)
// with createGuards, and any other callers, and the first set of guards can be created directly
// at the call site, without using optional.
return rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol) ||
rules.enabled(fixCleanup3_2_0) || rules.enabled(fixCleanup3_3_0);
rules.enabled(fixCleanup3_2_0) || rules.enabled(fixCleanup3_3_0) ||
rules.enabled(featureMPTokensV2);
}
void
+44 -10
View File
@@ -1232,6 +1232,19 @@ divide(STAmount const& num, STAmount const& den, Asset const& asset)
if (num == beast::kZero)
return {asset};
// The legacy path below assumes 16-digit mantissas. An MPT mantissa is
// the raw 63-bit value and is never scaled down, so an MPT numerator can
// overflow the quotient and an MPT denominator shortens it, moving the +5
// nudge into a significant digit. Use Number arithmetic under
// MPTokensV2 whenever an MPT is involved, as mulRound/divRound do. Pin
// the rounding mode so the result does not depend on the caller's.
if (isFeatureEnabled(featureMPTokensV2, /*resultIfNoRules*/ true) &&
(asset.holds<MPTIssue>() || num.holds<MPTIssue>() || den.holds<MPTIssue>()))
{
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return STAmount{asset, Number{num} / Number{den}};
}
std::uint64_t numVal = num.mantissa();
std::uint64_t denVal = den.mantissa();
int numOffset = num.exponent();
@@ -1261,11 +1274,21 @@ divide(STAmount const& num, STAmount const& den, Asset const& asset)
// numerator by 10^17 (the product is in the range of
// 10^32 to 10^33) followed by a division, so the result
// is in the range of 10^16 to 10^15.
return STAmount(
asset,
muldiv(numVal, kTenTO17, denVal) + 5,
numOffset - denOffset - 17,
num.negative() != den.negative());
std::uint64_t quotient = muldiv(numVal, kTenTO17, denVal);
int offset = numOffset - denOffset - 17;
// An integral (XRP) numerator is not scaled down to 16 digits, so the
// quotient can land in [2^63, 2^64). The STAmount constructor reads
// that as a negative IOU mantissa, and the +5 can wrap. Drop a digit
// first; the result is canonicalized to 16 digits anyway.
if (quotient > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max()) - 5 &&
isFeatureEnabled(featureMPTokensV2, /*resultIfNoRules*/ true))
{
quotient /= 10;
++offset;
}
return STAmount(asset, quotient + 5, offset, num.negative() != den.negative());
}
STAmount
@@ -1541,11 +1564,19 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro
bool const resultNegative = v1.negative() != v2.negative();
if (asset.holds<MPTIssue>() && isFeatureEnabled(featureMPTokensV2, false))
// The legacy path below assumes 16-digit mantissas. An MPT mantissa is the
// raw 63-bit balance, so any MPT operand (not just an MPT result) can
// overflow it: a large MPT amount times an IOU-shaped rate into an XRP or
// IOU result is the offer-crossing remainder and partial-fill case. Use
// Number arithmetic under MPTokensV2 whenever an MPT is involved.
//
// Outside a transaction (no current rules, e.g. pathfinding) default to
// the Number path: an MPT operand can only reach here through MPT
// offers or AMMs, which require MPTokensV2, so this keeps RPC quotes on
// the same arithmetic as the transaction they describe.
if (isFeatureEnabled(featureMPTokensV2, /*resultIfNoRules*/ true) &&
(asset.holds<MPTIssue>() || v1.holds<MPTIssue>() || v2.holds<MPTIssue>()))
{
// MPT DEX can combine 63-bit MPT amounts with IOU-shaped transfer
// rates. Use Number arithmetic under MPTokensV2 so the rounded
// operation is not limited by the legacy uint64_t scaled mantissa.
Number result;
{
NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp));
@@ -1643,7 +1674,10 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool
bool const resultNegative = (num.negative() != den.negative());
if (asset.holds<MPTIssue>() && isFeatureEnabled(featureMPTokensV2, false))
// See mulRoundImpl: any MPT operand, not just an MPT result, and the
// Number path when there are no current rules.
if (isFeatureEnabled(featureMPTokensV2, /*resultIfNoRules*/ true) &&
(asset.holds<MPTIssue>() || num.holds<MPTIssue>() || den.holds<MPTIssue>()))
{
// Match the multiply path above: Number performs the rounded
// operation, then STAmount materializes the final MPT amount using the
+70 -25
View File
@@ -83,6 +83,11 @@ protected:
std::optional<AMMLiquidity<TIn, TOut>> ammLiquidity_;
beast::Journal const j_;
Asset const strandDeliver_;
// MaximumAmount of book_.in if it is an MPT. Taking an MPT offer has the
// issuer send TakerPays to the owner, and a send above the cap is rejected
// (isMPTOverflow). forEachOffer limits the offer's input to the cap so it
// fills partially instead of failing the strand.
std::optional<TIn> maxIn_;
struct Cache
{
@@ -106,6 +111,12 @@ private:
, j_(ctx.j)
, strandDeliver_(ctx.strandDeliver)
{
if constexpr (std::is_same_v<TIn, MPTAmount>)
{
if (auto const max = maxMPTAmount(ctx.view, in.get<MPTIssue>().getMptID()))
maxIn_.emplace(*max);
}
if (auto const ammSle = ctx.view.read(keylet::amm(in, out));
ammSle && ammSle->getFieldAmount(sfLPTokenBalance) != beast::kZero)
{
@@ -817,31 +828,59 @@ BookStep<TIn, TOut, TDerived>::forEachOffer(
ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false);
stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
}
// Limit offer's input if MPT, BookStep is the first step (an issuer
// is making a cross-currency payment), and this offer is not owned
// by the issuer. Otherwise, OutstandingAmount may overflow.
auto const& issuer = assetIn.getIssuer();
if (isAssetInMPT && !prevStep_ && offer.owner() != issuer)
{
// Funds available to issue
auto const available = toAmount<TIn>(accountFunds(
sb,
issuer,
assetIn, // STAmount{0}, but the default is not used
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_));
if (stpAmt.in > available)
// Fee clip can floor in or out to 0. OfferStream is
// fee-blind, so it misses both: out==0 blocks the book;
// in==0 / out>0 consumes ownerGives for free. MPT at
// ordinary sizes; IOU only at the smallest out — V2
// because that IOU outcome changes.
if (sb.rules().enabled(featureMPTokensV2) &&
(stpAmt.out <= beast::kZero || ofrAmt.in <= beast::kZero))
{
limitStepIn(
offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available);
removeOffer(
"Removing offer whose fee-clipped amount rounds to "
"zero");
return true;
}
}
// Limit offer's input if MPT and the offer is not owned by the
// issuer (an issuer's send to itself is a no-op).
bool inLimited = false;
std::optional<TIn> limitIn;
auto const& issuer = assetIn.getIssuer();
if (isAssetInMPT && offer.owner() != issuer)
{
if (!prevStep_)
{
// BookStep is the first step (an issuer is making a
// cross-currency payment). Limit to the funds available to
// issue, otherwise OutstandingAmount may overflow.
auto const available = toAmount<TIn>(accountFunds(
sb,
issuer,
assetIn, // STAmount{0}, but the default is not used
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_));
if (stpAmt.in > available)
limitIn = available;
}
else if (maxIn_ && ofrAmt.in > *maxIn_)
{
// Limit to MaximumAmount (see maxIn_). The remainder is
// still funded, so the callback keeps the offer instead of
// consuming it. The grossed-up cap can't overflow since
// ofrAmt.in > maxIn_ did not.
inLimited = true;
limitIn = mulRatio(*maxIn_, ofrInRate, QUALITY_ONE, /*roundUp*/ true);
}
}
if (limitIn)
limitStepIn(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, *limitIn);
offerAttempted = true;
return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, inLimited);
}
catch (std::overflow_error const&)
{
@@ -1091,7 +1130,8 @@ BookStep<TIn, TOut, TDerived>::revImp(
TAmounts<TIn, TOut> const& stpAmt,
TOut const& ownerGives,
std::uint32_t transferRateIn,
std::uint32_t transferRateOut) mutable -> bool {
std::uint32_t transferRateOut,
bool inLimited) mutable -> bool {
if (remainingOut <= beast::kZero)
return false;
@@ -1103,8 +1143,10 @@ BookStep<TIn, TOut, TDerived>::revImp(
remainingOut = out - result.out;
this->consumeOffer(sb, offer, ofrAmt, stpAmt, ownerGives);
// return true b/c even if the payment is satisfied,
// we need to consume the offer
return true;
// we need to consume the offer. An offer limited to the issuance
// cap is not consumed, though: its remainder is still funded, and
// no more of that MPT can flow through this step anyway.
return !inLimited;
}
auto ofrAdjAmt = ofrAmt;
@@ -1207,7 +1249,8 @@ BookStep<TIn, TOut, TDerived>::fwdImp(
TAmounts<TIn, TOut> const& stpAmt,
TOut const& ownerGives,
std::uint32_t transferRateIn,
std::uint32_t transferRateOut) mutable -> bool {
std::uint32_t transferRateOut,
bool inLimited) mutable -> bool {
XRPL_ASSERT(cache_, "xrpl::BookStep::fwdImp::eachOffer : cache is set");
if (remainingIn <= beast::kZero)
@@ -1237,8 +1280,10 @@ BookStep<TIn, TOut, TDerived>::fwdImp(
savedInsAdj.insert(stpAmt.in);
lastOut = savedOutsAdj.insert(stpAmt.out);
resultAdj = TAmounts<TIn, TOut>(sum(savedInsAdj), sum(savedOutsAdj));
// consume the offer even if stepAmt.in == remainingIn
processMore = true;
// consume the offer even if stepAmt.in == remainingIn, unless it
// was limited to the issuance cap: its remainder is still funded
// and must stay on the book.
processMore = !inLimited;
}
else
{
+55 -10
View File
@@ -25,6 +25,7 @@
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTAmount.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Quality.h>
@@ -51,6 +52,7 @@
#include <functional>
#include <memory>
#include <optional>
#include <stdexcept>
#include <tuple>
#include <utility>
@@ -414,19 +416,47 @@ OfferCreate::flowCross(
// offer taker. Set sendMax to allow for the gateway's cut.
Rate gatewayXferRate{QUALITY_ONE};
STAmount sendMax = takerAmount.in;
// Payment flow code compares quality after the transfer rate is
// included, so the threshold is computed from the grossed-up input.
STAmount thresholdIn = sendMax;
if (!sendMax.native() && (accountID_ != sendMax.getIssuer()))
{
gatewayXferRate = transferRate(psb, sendMax);
if (gatewayXferRate.value != QUALITY_ONE)
{
sendMax =
multiplyRound(takerAmount.in, gatewayXferRate, takerAmount.in.asset(), true);
try
{
sendMax = multiplyRound(
takerAmount.in, gatewayXferRate, takerAmount.in.asset(), true);
thresholdIn = sendMax;
}
catch (std::overflow_error const&)
{
// Only an MPT amount can overflow here (IOU rescales and
// XRP has no transfer rate), and MPT offers require
// featureMPTokensV2 at preflight, so this legacy re-throw
// is unreachable in practice.
// LCOV_EXCL_START
if (!takerAmount.in.holds<MPTIssue>() ||
!psb.rules().enabled(featureMPTokensV2))
throw;
// LCOV_EXCL_STOP
// The grossed-up MPT amount exceeds 2^63-1. The taker can't
// send more than its balance anyway. The threshold only
// needs the ratio, so compute it as an IOU, which can't
// overflow.
sendMax = inStartBalance;
thresholdIn = multiplyRound(
STAmount{noIssue(), takerAmount.in.mantissa()},
gatewayXferRate,
noIssue(),
true);
}
}
}
// Payment flow code compares quality after the transfer rate is
// included. Since transfer rate is incorporated compute threshold.
Quality threshold{takerAmount.out, sendMax};
Quality threshold{takerAmount.out, thresholdIn};
// If we're creating a passive offer adjust the threshold so we only
// cross offers that have a better quality than this one.
@@ -871,15 +901,30 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
return {tesSUCCESS, true};
}
// A taker redeeming an MPT into this offer pays the transfer fee, and
// BookStep grosses up TakerPays by the rate for the whole offer before
// anything else. If that doesn't fit in an MPT amount, no taker can take
// the offer and BookStep would remove it on the first attempt. Checked on
// the remainder: it may fit even if the original amount didn't.
bool cannotRest = false;
if (mptV2 && saTakerPays.holds<MPTIssue>())
{
auto const rate = transferRate(sb, saTakerPays);
cannotRest = rate != kParityRate &&
!tryMulRatio(saTakerPays.mpt(), rate.value, QUALITY_ONE, /*roundUp*/ true);
}
// The remainder rests at uRate, the original pre-crossing rate. A zero
// rate (quality not representable) puts it in the directory whose index
// equals getBookBase(book), and BookTip scans keys strictly greater, so it
// could never be crossed while holding the owner's reserve. Don't place
// it; anything that crossed is kept, and a fully crossed offer has already
// returned above. Gated to preserve pre-amendment behavior.
if (mptV2 && uRate == 0)
// could never be crossed while holding the owner's reserve. Likewise, a
// bid whose fee-grossed TakerPays doesn't fit would be removed by BookStep
// on the first take. Don't place it; anything that crossed is kept, and a
// fully crossed offer has already returned above. Gated to preserve
// pre-amendment behavior.
if ((mptV2 && uRate == 0) || cannotRest)
{
JLOG(j_.debug()) << "Unrepresentable quality: remainder not placed";
JLOG(j_.debug()) << "Unrepresentable quality or fee: remainder not placed";
if (!crossed)
return {tecKILLED, false};
return {tesSUCCESS, true};
+15 -10
View File
@@ -31,6 +31,15 @@
namespace xrpl::test {
class AMMClawbackMPT_test : public beast::unit_test::Suite
{
// SingleAssetVault, LendingProtocol and MPTokensV2 enable the large
// Number mantissa.
static bool
largeMantissa(FeatureBitset const& features)
{
return features[featureSingleAssetVault] || features[featureLendingProtocol] ||
features[featureMPTokensV2];
}
void
testInvalidRequest(FeatureBitset features)
{
@@ -1724,7 +1733,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
.asString();
auto const lpTokenBalance =
amm.ammRpcInfo()[jss::amm][jss::lp_token][jss::value].asString();
if (features[featureSingleAssetVault] || features[featureLendingProtocol])
if (largeMantissa(features))
{
BEAST_EXPECT(lpToken == "1.414213562374011" && lpTokenBalance == "1.4142135623741");
}
@@ -1741,16 +1750,14 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt));
BEAST_EXPECT(!amm.ammExists());
}
else if (
features[fixAMMv1_3] &&
(features[featureSingleAssetVault] || features[featureLendingProtocol]))
else if (features[fixAMMv1_3] && largeMantissa(features))
{
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt));
// Without the Rounding feature and with new Number a dust pool
// amount remains
BEAST_EXPECT(amm.ammExists());
}
else if (!features[featureSingleAssetVault] && !features[featureLendingProtocol])
else if (!largeMantissa(features))
{
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt), Ter(tecINTERNAL));
BEAST_EXPECT(amm.ammExists());
@@ -1794,7 +1801,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
.asString();
auto const lpTokenBalance =
amm.ammRpcInfo()[jss::amm][jss::lp_token][jss::value].asString();
if (!features[featureSingleAssetVault] && !features[featureLendingProtocol])
if (!largeMantissa(features))
{
BEAST_EXPECT(lpToken == "1.414213562374011" && lpTokenBalance == "1.414213562374");
}
@@ -1811,16 +1818,14 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt));
BEAST_EXPECT(!amm.ammExists());
}
else if (
features[fixAMMv1_3] &&
(features[featureSingleAssetVault] || features[featureLendingProtocol]))
else if (features[fixAMMv1_3] && largeMantissa(features))
{
// Without the Rounding feature and with new Number a dust pool
// amount remains
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt));
BEAST_EXPECT(amm.ammExists());
}
else if (!features[featureSingleAssetVault] && !features[featureLendingProtocol])
else if (!largeMantissa(features))
{
env(amm::ammClawback(gw, alice, usd, eur, std::nullopt), Ter(tecINTERNAL));
BEAST_EXPECT(amm.ammExists());
+2 -2
View File
@@ -2857,8 +2857,8 @@ class AMMClawback_test : public beast::unit_test::Suite
{
// For now, just disable SAV entirely, which locks in the small Number
// mantissas
FeatureBitset const all =
jtx::testableAmendments() - featureSingleAssetVault - featureLendingProtocol;
FeatureBitset const all = jtx::testableAmendments() - featureSingleAssetVault -
featureLendingProtocol - featureMPTokensV2;
testInvalidRequest(all);
testInvalidRequest(all - featureMPTokensV2);
+6 -4
View File
@@ -116,7 +116,7 @@ private:
Txflags(tfPartialPayment));
BEAST_EXPECT(ammCarol.expectBalances(
btc(1'001'000'000'374'816), eth(100'000'000'000'000'000), ammCarol.tokens()));
btc(1'001'000'000'374'814), eth(100'000'000'000'000'000), ammCarol.tokens()));
env.require(Balance(bob_, eth(200'100'000'000'000'000)));
BEAST_EXPECT(isOffer(env, carol_, btc(49'000'000'000'000), XRP(49)));
@@ -2269,13 +2269,15 @@ private:
// on 24e12GBP
// 1,200e12 - 24e12*1.25 =~ 1,170e12GBP
env.require(Balance(alice_, gbp(1'170'000'000'000'000)));
// 24e12GBP is swapped in for 28.125e12BTC
// 24e12GBP is swapped in for 28.125e12BTC, less one unit: the
// limit quality 120/90 is rounded to nearest, so a trade at
// exactly 4/3 is just over the limit
BEAST_EXPECT(amm.expectBalances(
gbp(1'024'000'000'000'000), btc(1'171'875'000'000'000), amm.tokens()));
gbp(1'024'000'000'000'000), btc(1'171'875'000'000'001), amm.tokens()));
// 25% on 22.5e12BTC is paid in tr fee
// 22.5*1.25 = 28.125e12BTC
env.require(Balance(carol_, btc(1'222'500'000'000'000)));
env.require(Balance(carol_, btc(1'222'499'999'999'999)));
}
{
// Payment via offer and AMM with limit quality, deliver less
+2 -1
View File
@@ -73,7 +73,8 @@ class AMMExtended_test : public jtx::AMMTest
// For now, just disable SAV entirely, which locks in the small Number
// mantissas
FeatureBitset const all_{
testableAmendments() - featureSingleAssetVault - featureLendingProtocol};
testableAmendments() - featureSingleAssetVault - featureLendingProtocol -
featureMPTokensV2};
private:
void
+26 -23
View File
@@ -1451,12 +1451,14 @@ private:
auto carolXRP = env.balance(carol_, XRP);
auto carolMPT = env.balance(carol_, MPT(ammAlice[1]));
// The exact deposit is 201; rounding in the pool's favor at
// the large mantissa takes one more unit.
ammAlice.deposit(carol_, 100'000, MPT(ammAlice[1])(205));
BEAST_EXPECT(ammAlice.expectBalances(
XRP(10'000), MPT(ammAlice[1])(10'201), IOUAmount{10'100'000, 0}));
XRP(10'000), MPT(ammAlice[1])(10'202), IOUAmount{10'100'000, 0}));
env.require(Balance(carol_, carolXRP - drops(baseFee)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(201)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(202)));
},
{{XRP(10'000), gAmmmpt(10'000)}});
@@ -1467,11 +1469,13 @@ private:
auto carolXRP = env.balance(carol_, XRP);
auto carolMPT = env.balance(carol_, MPT(ammAlice[1]));
// The exact deposit is 201 XRP; rounding in the pool's favor
// at the large mantissa takes one more drop.
ammAlice.deposit(carol_, 100'000, XRP(205));
BEAST_EXPECT(ammAlice.expectBalances(
XRP(10'201), MPT(ammAlice[1])(10'000), IOUAmount{10'100'000, 0}));
XRPAmount{10'201'000'001}, MPT(ammAlice[1])(10'000), IOUAmount{10'100'000, 0}));
env.require(Balance(carol_, carolXRP - XRP(201) - drops(baseFee)));
env.require(Balance(carol_, carolXRP - XRPAmount{201'000'001} - drops(baseFee)));
env.require(Balance(carol_, carolMPT));
},
{{XRP(10'000), gAmmmpt(10'000)}});
@@ -1551,10 +1555,10 @@ private:
STAmount{ammAlice.lptIssue(), 2004, -6});
BEAST_EXPECT(ammAlice.expectBalances(
XRP(10'000), MPT(ammAlice[1])(10'081), IOUAmount{10'039'920'31840891, -8}));
XRP(10'000), MPT(ammAlice[1])(10'080), IOUAmount{10'039'920'3184089, -7}));
env.require(Balance(carol_, carolXRP - drops(baseFee)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(81)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(80)));
},
{{XRP(10'000), gAmmmpt(10'000)}});
@@ -1573,10 +1577,10 @@ private:
STAmount{ammAlice.lptIssue(), 2004, -6});
BEAST_EXPECT(ammAlice.expectBalances(
XRP(10'000), MPT(ammAlice[1])(10'081), IOUAmount{10'039'920'31840891, -8}));
XRP(10'000), MPT(ammAlice[1])(10'080), IOUAmount{10'039'920'3184089, -7}));
env.require(Balance(carol_, carolXRP - drops(baseFee)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(81)));
env.require(Balance(carol_, carolMPT - MPT(ammAlice[1])(80)));
},
{{XRP(10'000), gAmmmpt(10'000)}});
@@ -1797,9 +1801,9 @@ private:
ammAlice.deposit(gw_, MPT(ammAlice[0])(1000));
BEAST_EXPECT(ammAlice.expectBalances(
MPT(ammAlice[0])(1'010'999),
MPT(ammAlice[0])(1'011'000),
MPT(ammAlice[1])(1'010'000),
IOUAmount{1'010'499'376546071, -9}));
IOUAmount{1'010'499'876298854, -9}));
},
{{gAmmmpt(10'000), gAmmmpt(10'000)}});
@@ -2212,7 +2216,7 @@ private:
if (env.enabled(fixAMMv1_3))
{
BEAST_EXPECT(ammAlice.expectBalances(
MPT(ammAlice[0])(1), STAmount{USD, 1, -11}, IOUAmount{1, -8}));
MPT(ammAlice[0])(1), STAmount{USD, 1, -12}, IOUAmount{1, -9}));
}
},
{{gAmmmpt(10'000'000'000), USD(10'000)}},
@@ -2541,11 +2545,12 @@ private:
// XRP amount to withdraw is 0
ammAlice.withdraw(
alice_, IOUAmount{1, -5}, std::nullopt, std::nullopt, Ter(tecAMM_FAILED));
// Calculated tokens to withdraw are 0
// Calculated tokens to withdraw are 0: 1e-16 of 10,000 USD is
// below the large Number mantissa's precision
ammAlice.withdraw(
alice_,
std::nullopt,
STAmount{USD, 1, -11},
STAmount{USD, 1, -16},
std::nullopt,
Ter(tecAMM_INVALID_TOKENS));
ammAlice.deposit(carol_, STAmount{USD, 1, -10});
@@ -2707,7 +2712,7 @@ private:
BEAST_EXPECT(ammAlice.expectBalances(
XRPAmount(9000'000001),
MPT(ammAlice[1])(10'000),
IOUAmount{9'486'832'98050514, -8}));
IOUAmount{9'486'832'980505138, -9}));
},
{{XRP(10'000), gAmmmpt(10'000)}});
testAMM(
@@ -2715,7 +2720,7 @@ private:
// single withdraw MPT from XRP/MPT
ammAlice.withdraw(alice_, MPT(ammAlice[1])(1'000));
BEAST_EXPECT(ammAlice.expectBalances(
XRP(10000), MPT(ammAlice[1])(9001), IOUAmount{9'486'832'98050514, -8}));
XRP(10000), MPT(ammAlice[1])(9001), IOUAmount{9'486'832'980505138, -9}));
},
{{XRP(10'000), gAmmmpt(10'000)}});
testAMM(
@@ -2723,9 +2728,7 @@ private:
// single withdraw IOU from IOU/MPT
ammAlice.withdraw(alice_, USD(1'000));
BEAST_EXPECT(ammAlice.expectBalances(
STAmount{USD, UINT64_C(9000'000000000004), -12},
MPT(ammAlice[1])(10'000),
IOUAmount{9486'83298050514, -11}));
USD(9'000), MPT(ammAlice[1])(10'000), IOUAmount{9486'832980505138, -12}));
},
{{USD(10'000), gAmmmpt(10'000)}});
testAMM(
@@ -2733,7 +2736,7 @@ private:
// single withdraw MPT from IOU/MPT
ammAlice.withdraw(alice_, MPT(ammAlice[1])(1'000));
BEAST_EXPECT(ammAlice.expectBalances(
USD(10'000), MPT(ammAlice[1])(9001), IOUAmount{9486'83298050514, -11}));
USD(10'000), MPT(ammAlice[1])(9001), IOUAmount{9486'832980505138, -12}));
},
{{USD(10'000), gAmmmpt(10'000)}});
testAMM(
@@ -2743,7 +2746,7 @@ private:
BEAST_EXPECT(ammAlice.expectBalances(
MPT(ammAlice[0])(9001),
MPT(ammAlice[1])(10'000),
IOUAmount{9486'83298050514, -11}));
IOUAmount{9486'832980505138, -12}));
},
{{gAmmmpt(10'000), gAmmmpt(10'000)}});
@@ -3024,7 +3027,7 @@ private:
ammAlice.deposit(carol_, 1'000);
ammAlice.withdrawAll(carol_, USD(0));
BEAST_EXPECT(ammAlice.expectBalances(
STAmount{USD, UINT64_C(9'090'909090909092), -12},
STAmount{USD, UINT64_C(9'090'909090909091), -12},
MPT(ammAlice[1])(11'000),
IOUAmount{10'000}));
},
@@ -3249,7 +3252,7 @@ private:
BEAST_EXPECT(ammAlice.expectBalances(
MPT(ammAlice[0])(10'000'000'000),
STAmount{USD, 1, -11},
IOUAmount{316227765, -9}));
IOUAmount{316227766, -9}));
},
{{gAmmmpt(10'000'000'000), USD(10'000)}});
// XRP
@@ -5960,7 +5963,7 @@ private:
if (!features[fixAMMv1_3])
{
BEAST_EXPECT(tokensFee == IOUAmount(98'019'80198019, -8));
BEAST_EXPECT(tokensNoFee == IOUAmount(98'495'13933556, -8));
BEAST_EXPECT(tokensNoFee == IOUAmount(98'495'13933557, -8));
}
else
{
+2 -1
View File
@@ -84,7 +84,8 @@ private:
{
// For now, just disable SAV entirely, which locks in the small Number
// mantissas
return jtx::testableAmendments() - featureSingleAssetVault - featureLendingProtocol;
return jtx::testableAmendments() - featureSingleAssetVault - featureLendingProtocol -
featureMPTokensV2;
}
// Seed from the local testableAmendments() which strips SAV and Lending.
+5 -5
View File
@@ -573,8 +573,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
env(pay(gw, bob, usd(1)));
env.close();
bool const largeMantissa =
features[featureSingleAssetVault] || features[featureLendingProtocol];
bool const largeMantissa = features[featureSingleAssetVault] ||
features[featureLendingProtocol] || features[featureMPTokensV2];
// alice cannot create escrow for 1/10 iou - precision loss
env(escrow::create(alice, bob, usd(1)),
@@ -2294,8 +2294,8 @@ struct EscrowToken_test : public beast::unit_test::Suite
env(pay(gw, bob, usd(1)));
env.close();
bool const largeMantissa =
features[featureSingleAssetVault] || features[featureLendingProtocol];
bool const largeMantissa = features[featureSingleAssetVault] ||
features[featureLendingProtocol] || features[featureMPTokensV2];
// alice cannot create escrow for 1/10 iou - precision loss
env(escrow::create(alice, bob, usd(1)),
@@ -4334,7 +4334,7 @@ public:
using namespace test::jtx;
FeatureBitset const all{testableAmendments()};
for (FeatureBitset const& feats :
{all - featureSingleAssetVault - featureLendingProtocol, all})
{all - featureSingleAssetVault - featureLendingProtocol - featureMPTokensV2, all})
{
testIOUWithFeats(feats);
testIOUWithFeats(feats - fixCleanup3_2_0);
+336 -17
View File
@@ -41,9 +41,11 @@
#include <xrpl/tx/paths/detail/Steps.h>
#include <cstdint>
#include <initializer_list>
#include <optional>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace xrpl::test {
@@ -782,6 +784,335 @@ struct FlowMPT_test : public beast::unit_test::Suite
test(maxRepresentable + 1, kMaxMpTokenAmount, "limiting");
}
void
testBookStepInputAboveMaximumAmount(FeatureBitset features)
{
// OfferCreate doesn't compare TakerPays with the MPT's MaximumAmount,
// so a bid for more than the cap rests. Taking it has the issuer send
// TakerPays to the owner, and isMPTOverflow() rejects a single send
// above the cap. BookStep now limits the offer's input to the cap so
// the bid fills partially, like an underfunded offer, instead of
// throwing out of consumeOffer() and drying the strand (which left the
// bid on the book to dead-end every payment that crossed it).
//
// XRP -> x -> y. carol asks the whole 1,000 x supply for XRP(1,000);
// mallory bids x for 2,000 y; alice pays dave y.
testcase("BookStep input above MaximumAmount");
using namespace jtx;
Account const gwX("gwX");
Account const gwY("gwY");
Account const alice("alice");
Account const carol("carol");
Account const mallory("mallory");
Account const dave("dave");
std::int64_t constexpr cap = 1'000;
std::int64_t constexpr bidGets = 2'000;
// A bid above the cap at a non-unit price.
std::int64_t constexpr unevenBidPays = (3 * cap) + 100;
auto test = [&](Account const& bidder,
std::int64_t bidPays,
std::int64_t deliver,
bool partial,
std::int64_t delivered,
std::optional<TER> const& ter = std::nullopt) {
Env env(*this, features);
env.fund(XRP(100'000), gwX, gwY, alice, carol, mallory, dave);
env.close();
MPT const x = MPTTester(
{.env = env, .issuer = gwX, .holders = {carol, mallory, dave}, .maxAmt = cap});
MPT const y = MPTTester({.env = env, .issuer = gwY, .holders = {mallory, dave, gwX}});
env(pay(gwX, carol, x(cap)));
env(pay(gwY, bidder, y(bidGets)));
env.close();
env(offer(carol, XRP(cap), x(cap)));
auto const bidSeq = env.seq(bidder);
env(offer(bidder, x(bidPays), y(bidGets)));
env.close();
auto const bidKeylet = keylet::offer(bidder.id(), SeqProxy::rawSequence(bidSeq));
BEAST_EXPECT(env.le(bidKeylet) != nullptr);
auto const flags = partial ? tfNoRippleDirect | tfPartialPayment : tfNoRippleDirect;
env(pay(alice, dave, y(deliver)),
Path(~x, ~y),
Sendmax(XRP(5'000)),
Txflags(flags),
ter ? Ter(*ter) : Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(env.balance(dave, y) == y(delivered));
// Each x delivered through the bid costs bidGets/bidPays y.
std::int64_t const xTaken = delivered * bidPays / bidGets;
if (bidder != gwX)
BEAST_EXPECT(env.balance(bidder, x) == x(xTaken));
BEAST_EXPECT(env.balance(carol, x) == x(cap - xTaken));
auto const sle = env.le(bidKeylet);
if (xTaken == bidPays)
{
BEAST_EXPECT(!sle);
}
else if (BEAST_EXPECT(sle))
{
BEAST_EXPECT((*sle)[sfTakerPays] == x(bidPays - xTaken));
BEAST_EXPECT((*sle)[sfTakerGets] == y(bidGets - delivered));
}
auto const issuance = env.le(keylet::mptokenIssuance(x.mpt()));
if (BEAST_EXPECT(issuance))
{
// x taken by the issuer's own bid is redeemed.
std::int64_t const outstanding = bidder == gwX ? cap - xTaken : cap;
BEAST_EXPECT(
(*issuance)[sfOutstandingAmount] == static_cast<std::uint64_t>(outstanding));
}
};
// Bid above the cap: the input is limited to the cap, so at most
// 1,000 x (= 1,000 y at this bid's price) can flow through it.
test(mallory, 2 * cap, 1'001, true, cap);
test(mallory, 2 * cap, bidGets, true, cap);
// Not partial: the strand no longer throws, but 1,001 y can't be
// delivered.
test(mallory, 2 * cap, 1'001, false, 0, tecPATH_PARTIAL);
// Controls: a request within the cap, and a bid at the cap.
test(mallory, 2 * cap, 500, true, 500);
test(mallory, cap, bidGets, true, bidGets);
// The issuer's own bid above the cap isn't limited: the issuer's send
// to itself is a no-op, so upstream supply bounds the fill as usual.
test(gwX, 2 * cap, 1'001, true, cap);
// A bid above the cap at a non-unit price, crossed repeatedly. Each
// crossing is limited to the cap and the output is rounded down, so
// the remainder's quality is no worse than the original's. The
// remainder stays on the book and is eventually consumed.
{
Env env(*this, features);
env.fund(XRP(100'000), gwX, gwY, alice, carol, mallory, dave);
env.close();
MPT const x = MPTTester(
{.env = env, .issuer = gwX, .holders = {carol, mallory, dave}, .maxAmt = cap});
MPT const y = MPTTester({.env = env, .issuer = gwY, .holders = {mallory, dave}});
env(pay(gwX, carol, x(cap)));
env(pay(gwY, mallory, y(bidGets)));
env.close();
auto const bidSeq = env.seq(mallory);
env(offer(mallory, x(unevenBidPays), y(bidGets)));
env.close();
auto const bidKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(bidSeq));
// {x taken, y delivered} per round; the first three are capped.
// 1,000 x buys floor(1,000 * gets / pays) y at the remainder's
// price: 2,000/3,100, 1,355/2,100, 710/1,100. The last 100 x buy
// the remaining 65 y and consume the bid.
std::int64_t totalY = 0;
std::int64_t bidPays = unevenBidPays;
std::int64_t bidGetsLeft = bidGets;
for (auto const& [xTaken, yDelivered] :
std::initializer_list<std::pair<std::int64_t, std::int64_t>>{
{cap, 645}, {cap, 645}, {cap, 645}, {100, 65}})
{
auto const askSeq = env.seq(carol);
env(offer(carol, XRP(cap), x(cap)));
env.close();
env(pay(alice, dave, y(1'001)),
Path(~x, ~y),
Sendmax(XRP(5'000)),
Txflags(tfNoRippleDirect | tfPartialPayment));
env.close();
totalY += yDelivered;
bidPays -= xTaken;
bidGetsLeft -= yDelivered;
BEAST_EXPECT(env.balance(dave, y) == y(totalY));
BEAST_EXPECT(env.balance(mallory, x) == x(xTaken));
BEAST_EXPECT(env.balance(carol, x) == x(cap - xTaken));
auto const sle = env.le(bidKeylet);
if (bidPays == 0)
{
BEAST_EXPECT(!sle);
}
else if (BEAST_EXPECT(sle))
{
BEAST_EXPECT((*sle)[sfTakerPays] == x(bidPays));
BEAST_EXPECT((*sle)[sfTakerGets] == y(bidGetsLeft));
// The remainder's quality is no worse than the original's.
BEAST_EXPECT(bidGetsLeft * unevenBidPays >= bidGets * bidPays);
}
// Return x to carol for the next round, and cancel her unfilled
// ask remainder.
env(pay(mallory, carol, x(xTaken)));
env(offerCancel(carol, askSeq));
env.close();
}
BEAST_EXPECT(totalY == bidGets);
}
// A transfer fee on x: the cap is grossed up by the rate when limiting
// the taker's input, and the owner receives exactly the cap. The
// previous step must redeem for the fee to apply, so alice pays with
// x directly (MPTEndpoint alice -> gwX, then the x/y book). alice can
// hold at most the cap, so upstream still bounds the fill: she pays
// 1,000 gross, mallory receives floor(1,000 / 1.1) = 909 x, dave gets
// floor(909 * 2,000 / 3,100) = 586 y.
{
Env env(*this, features);
env.fund(XRP(100'000), gwX, gwY, alice, mallory, dave);
env.close();
MPT const x = MPTTester(
{.env = env,
.issuer = gwX,
.holders = {alice, mallory, dave},
.transferFee = 10'000,
.maxAmt = cap});
MPT const y = MPTTester({.env = env, .issuer = gwY, .holders = {mallory, dave}});
env(pay(gwX, alice, x(cap)));
env(pay(gwY, mallory, y(bidGets)));
env.close();
auto const bidSeq = env.seq(mallory);
env(offer(mallory, x(unevenBidPays), y(bidGets)));
env.close();
auto const bidKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(bidSeq));
env(pay(alice, dave, y(1'001)),
Path(~y),
Sendmax(x(cap)),
Txflags(tfNoRippleDirect | tfPartialPayment));
env.close();
BEAST_EXPECT(env.balance(dave, y) == y(586));
BEAST_EXPECT(env.balance(mallory, x) == x(909));
BEAST_EXPECT(env.balance(alice, x) == x(0));
auto const sle = env.le(bidKeylet);
if (BEAST_EXPECT(sle))
{
BEAST_EXPECT((*sle)[sfTakerPays] == x(unevenBidPays - 909));
BEAST_EXPECT((*sle)[sfTakerGets] == y(bidGets - 586));
}
auto const issuance = env.le(keylet::mptokenIssuance(x.mpt()));
if (BEAST_EXPECT(issuance))
BEAST_EXPECT((*issuance)[sfOutstandingAmount] == 909);
}
// Two bids above the cap at the same quality, mallory's ahead of
// bob's. Stopping after a cap-limited bid doesn't strand bob's: the
// step's input is already at the cap, which is all carol can supply,
// so delivery equals the single-bid case. If mallory's bid is
// underfunded below the cap instead, it isn't cap-limited: it is
// consumed, removed, and bob's bid is crossed in the same pass.
auto twoBids = [&](std::int64_t malloryY,
std::int64_t delivered,
std::int64_t malloryX,
std::int64_t malloryYSold,
std::int64_t bobX,
std::int64_t bobYSold,
std::optional<std::pair<std::int64_t, std::int64_t>> malloryBid) {
Env env(*this, features);
Account const bob("bob");
env.fund(XRP(100'000), gwX, gwY, alice, bob, carol, mallory, dave);
env.close();
MPT const x = MPTTester(
{.env = env, .issuer = gwX, .holders = {bob, carol, mallory, dave}, .maxAmt = cap});
MPT const y = MPTTester({.env = env, .issuer = gwY, .holders = {bob, mallory, dave}});
env(pay(gwX, carol, x(cap)));
env(pay(gwY, mallory, y(malloryY)));
env(pay(gwY, bob, y(bidGets)));
env.close();
env(offer(carol, XRP(cap), x(cap)));
// Close between the bids: within one ledger, transactions apply in
// canonical order, and mallory's bid must be ahead of bob's.
auto const mallorySeq = env.seq(mallory);
env(offer(mallory, x(unevenBidPays), y(bidGets)));
env.close();
auto const bobSeq = env.seq(bob);
env(offer(bob, x(unevenBidPays), y(bidGets)));
env.close();
env(pay(alice, dave, y(bidGets)),
Path(~x, ~y),
Sendmax(XRP(5'000)),
Txflags(tfNoRippleDirect | tfPartialPayment));
env.close();
BEAST_EXPECT(env.balance(dave, y) == y(delivered));
BEAST_EXPECT(env.balance(mallory, x) == x(malloryX));
BEAST_EXPECT(env.balance(mallory, y) == y(malloryY - malloryYSold));
BEAST_EXPECT(env.balance(bob, x) == x(bobX));
BEAST_EXPECT(env.balance(bob, y) == y(bidGets - bobYSold));
BEAST_EXPECT(env.balance(carol, x) == x(cap - malloryX - bobX));
auto const mallorySle =
env.le(keylet::offer(mallory.id(), SeqProxy::rawSequence(mallorySeq)));
if (!malloryBid)
{
BEAST_EXPECT(!mallorySle);
}
else if (BEAST_EXPECT(mallorySle))
{
BEAST_EXPECT((*mallorySle)[sfTakerPays] == x(malloryBid->first));
BEAST_EXPECT((*mallorySle)[sfTakerGets] == y(malloryBid->second));
}
auto const bobSle = env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobSeq)));
if (BEAST_EXPECT(bobSle))
{
BEAST_EXPECT((*bobSle)[sfTakerPays] == x(unevenBidPays - bobX));
BEAST_EXPECT((*bobSle)[sfTakerGets] == y(bidGets - bobYSold));
}
auto const issuance = env.le(keylet::mptokenIssuance(x.mpt()));
if (BEAST_EXPECT(issuance))
BEAST_EXPECT((*issuance)[sfOutstandingAmount] == static_cast<std::uint64_t>(cap));
};
// Both funded: mallory's bid takes the cap for 645 y and rests at
// 2,100/1,355; bob's is untouched.
twoBids(bidGets, 645, cap, 645, 0, 0, std::pair{unevenBidPays - cap, bidGets - 645});
// mallory holds 500 y: funds limit her bid to 775 x, under the cap, so
// it is consumed and removed; bob's bid takes the remaining 225 x for
// 145 y in the same pass. Same 645 y total.
twoBids(500, 645, 775, 500, 225, 145, std::nullopt);
}
void
testBookStepMissingIssuance(FeatureBitset features)
{
// A path through a book whose MPT issuance doesn't exist. BookStep's
// constructor looks the issuance up for MaximumAmount before check()
// rejects the step, so the lookup must tolerate a missing entry.
testcase("BookStep on a missing MPT issuance");
using namespace jtx;
Env env(*this, features);
Account const gw("gw");
Account const alice("alice");
Account const bob("bob");
env.fund(XRP(10'000), gw, alice, bob);
env.close();
MPT const y = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}});
MPT const missing{"missing", makeMptID(99, gw)};
BEAST_EXPECT(!env.le(keylet::mptokenIssuance(missing.mpt())));
env(pay(alice, bob, y(10)),
Path(~missing, ~y),
Sendmax(XRP(100)),
Txflags(tfNoRippleDirect),
Ter(tecOBJECT_NOT_FOUND));
env.close();
BEAST_EXPECT(env.balance(bob, y) == y(0));
}
void
testFalseDry(FeatureBitset features)
{
@@ -1112,14 +1443,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
{
auto const offer = *offerPtr;
BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER);
if constexpr (std::is_same_v<std::decay_t<decltype(eur)>, IOU>)
{
BEAST_EXPECT(offer[sfTakerGets] == eur(5'988));
}
else
{
BEAST_EXPECT(offer[sfTakerGets] == eur(5'989));
}
BEAST_EXPECT(offer[sfTakerGets] == eur(5'988));
BEAST_EXPECT(offer[sfTakerPays] == usd(4'990));
}
};
@@ -1158,10 +1482,8 @@ struct FlowMPT_test : public beast::unit_test::Suite
// OutstandingAmount since it doesn't know if the
// BookStep redeems or not. The BookStep then has 600EUR
// available. Consequently, the entire offer is crossed.
// Note remaining takerGets is 541 rather than 540 due to integral
// rounding. XRP has a similar result.
return TokenData<MPT, MPT>{
.gets = eur, .pays = usd, .remTakerGets = eur(541), .remTakerPays = usd(450)};
.gets = eur, .pays = usd, .remTakerGets = eur(540), .remTakerPays = usd(450)};
};
auto initXRP = [&](Env& env) {
@@ -1171,13 +1493,8 @@ struct FlowMPT_test : public beast::unit_test::Suite
// OutstandingAmount since it doesn't know if the
// BookStep redeems or not. The BookStep then has 600EUR
// available. Consequently, the entire offer is crossed.
// Note remaining takerGets is 540.000001 rather than 540 due to
// integral rounding.
return TokenData<XrpT, MPT>{
.gets = XRP,
.pays = usd,
.remTakerGets = XRP(540.000001),
.remTakerPays = usd(450)};
.gets = XRP, .pays = usd, .remTakerGets = XRP(540), .remTakerPays = usd(450)};
};
auto initIOU = [&](Env& env) {
@@ -2525,6 +2842,8 @@ struct FlowMPT_test : public beast::unit_test::Suite
testTransferRate(features);
testMPTEndpointTransferRateOverflow(features);
testMPTEndpointRipplingInputOverflow(features);
testBookStepInputAboveMaximumAmount(features);
testBookStepMissingIssuance(features);
testSelfPayment1(features);
testSelfPayment2(features);
testSelfFundedXRPEndpoint(false, features);
File diff suppressed because it is too large. Load diff
+8 -7
View File
@@ -139,14 +139,15 @@ AMMTestBase::testAMM(std::function<void(jtx::AMM&, jtx::Env&)> const& cb, TestAM
// Use small Number mantissas for the life of this test.
NumberMantissaScaleGuard const sg{xrpl::MantissaRange::MantissaScale::Small};
// For now, just disable SAV entirely, which locks in the small Number
// mantissas
Env env{
*this,
features - featureSingleAssetVault - featureLendingProtocol,
arg.noLog ? std::make_unique<CaptureLogs>(&logs) : nullptr};
auto const [asset1, asset2] = arg.pool ? *arg.pool : std::make_pair(XRP(10000), USD(10000));
// For now, just disable SAV entirely, which locks in the small Number
// mantissas. MPTokensV2 enables them too, so keep it for MPT pools
// only, which require it.
auto envFeatures = features - featureSingleAssetVault - featureLendingProtocol;
if (!asset1.holds<MPTIssue>() && !asset2.holds<MPTIssue>())
envFeatures = envFeatures - featureMPTokensV2;
Env env{*this, envFeatures, arg.noLog ? std::make_unique<CaptureLogs>(&logs) : nullptr};
auto toFund = [&](STAmount const& a) -> STAmount {
if (a.native())
{
+128
View File
@@ -1072,6 +1072,39 @@ public:
BEAST_EXPECT(down.signum() > 0);
BEAST_EXPECT(up >= down);
}
{
// An MPT operand with a non-MPT result. The legacy path assumes
// 16-digit mantissas and overflows on a 63-bit MPT mantissa; under
// MPTokensV2 any MPT operand, not only an MPT result, takes the
// Number path. This is OfferCreate::flowCross recomputing the
// remainder of a partially crossed large MPT offer, and
// Quality::ceilIn/ceilOut partially filling one.
Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
STAmount const bigMpt{asset, UINT64_C(5'000'000'000'000'000'000)};
STAmount const milli{noIssue(), UINT64_C(1'000'000'000'000'000), -18}; // 1e-3
STAmount const kilo{noIssue(), UINT64_C(1'000'000'000'000'000), -12}; // 1e3
STAmount const tenMicro{noIssue(), UINT64_C(1'000'000'000'000'000), -20}; // 1e-5
STAmount const usdResult{usd, UINT64_C(5'000'000'000'000'000)};
XRPAmount const xrpResult{50'000'000'000'000};
{
CurrentTransactionRulesGuard const rg(rules(false));
throwsOverflow([&] { (void)mulRound(bigMpt, milli, usd, true); });
throwsOverflow([&] { (void)mulRound(bigMpt, tenMicro, xrpIssue(), true); });
throwsOverflow([&] { (void)divRound(bigMpt, kilo, usd, true); });
throwsOverflow([&] { (void)divRoundStrict(bigMpt, kilo, usd, false); });
}
{
CurrentTransactionRulesGuard const rg(rules(true));
BEAST_EXPECT(mulRound(bigMpt, milli, usd, true) == usdResult);
BEAST_EXPECT(mulRound(bigMpt, milli, usd, false) == usdResult);
BEAST_EXPECT(mulRound(bigMpt, tenMicro, xrpIssue(), true) == STAmount{xrpResult});
BEAST_EXPECT(divRound(bigMpt, kilo, usd, true) == usdResult);
BEAST_EXPECT(divRoundStrict(bigMpt, kilo, usd, false) == usdResult);
}
}
}
void
@@ -1336,6 +1369,100 @@ public:
//--------------------------------------------------------------------------
void
testMPTGetRate()
{
testcase("getRate with a large integral numerator");
MPTIssue const asset{makeMptID(1, AccountID(0x4985601))};
Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)};
auto rules = [](bool const mptV2) {
static std::unordered_set<uint256, beast::Uhash<>> const kNoFeatures;
// MPTokensV2 with the large Number mantissa (SAV enables it).
static std::unordered_set<uint256, beast::Uhash<>> const kMptV2Features{
featureMPTokensV2, featureSingleAssetVault, fixCleanup3_3_0};
return Rules{mptV2 ? kMptV2Features : kNoFeatures};
};
// TakerPays / TakerGets = 184467440737095516 / 1e-7. The legacy
// divide() quotient is 2^64 - 11, which the signed IOU mantissa reads
// as -11: getRate records 1.1e6 instead of ~1.8e24.
STAmount const dust{usd, UINT64_C(1'000'000'000'000'000), -22}; // 1e-7
STAmount const largeMpt{asset, UINT64_C(184'467'440'737'095'516)};
STAmount const trueRate{noIssue(), Number{largeMpt} / Number{dust}};
// Quotient above 2^64: muldiv throws, so getRate returns 0
// pre-amendment.
STAmount const one{usd, UINT64_C(1'000'000'000'000'000), -15};
STAmount const hugeMpt{asset, UINT64_C(5'000'000'000'000'000'000)};
STAmount const hugeMptRate{noIssue(), UINT64_C(5'000'000'000'000'000), 3};
// Over the smallest IOU the rate is beyond the IOU exponent range, so
// getRate returns 0 either way.
STAmount const tiny{usd, STAmount::kMinValue, STAmount::kMinOffset};
// An integral result keeps every digit, so it must come out exact.
STAmount const maxMpt{asset, static_cast<std::uint64_t>(kMaxMpTokenAmount)};
STAmount const oneAndHalf{noIssue(), UINT64_C(1'500'000'000'000'000), -15};
STAmount const e18Mpt{asset, UINT64_C(1'000'000'000'000'000'000)};
// XRP numerator in the same band: 9.5e16 drops over a mantissa of
// 1e15 gives a legacy quotient of 9.5e18.
STAmount const hugeXrp{XRPAmount{95'000'000'000'000'000}};
STAmount const hugeXrpRate{noIssue(), UINT64_C(9'500'000'000'000'000), 1};
{
CurrentTransactionRulesGuard const rg(rules(false));
BEAST_EXPECT(
amountFromQuality(getRate(dust, largeMpt)) ==
STAmount(noIssue(), UINT64_C(1'100'000'000'000'000), -9));
BEAST_EXPECT(getRate(one, hugeMpt) == 0);
BEAST_EXPECT(getRate(tiny, hugeMpt) == 0);
BEAST_EXPECT(getRate(one, hugeXrp) != 0);
BEAST_EXPECT(amountFromQuality(getRate(one, hugeXrp)) != hugeXrpRate);
bool threw = false;
try
{
(void)divide(hugeMpt, one, asset);
}
catch (std::overflow_error const&)
{
threw = true;
}
BEAST_EXPECT(threw);
}
{
CurrentTransactionRulesGuard const rg(rules(true));
BEAST_EXPECT(amountFromQuality(getRate(dust, largeMpt)) == trueRate);
BEAST_EXPECT(amountFromQuality(getRate(one, hugeMpt)) == hugeMptRate);
BEAST_EXPECT(getRate(tiny, hugeMpt) == 0);
BEAST_EXPECT(amountFromQuality(getRate(one, hugeXrp)) == hugeXrpRate);
BEAST_EXPECT(!divide(largeMpt, dust, noIssue()).negative());
BEAST_EXPECT(divide(hugeMpt, one, asset) == hugeMpt);
BEAST_EXPECT(divide(maxMpt, one, asset) == maxMpt);
BEAST_EXPECT(
divide(STAmount{asset, UINT64_C(9'000'000'000'000'000'000)}, oneAndHalf, asset) ==
STAmount(asset, UINT64_C(6'000'000'000'000'000'000)));
// An unscaled MPT denominator no longer moves the +5 nudge into
// the 15th digit: 9e15 / 1e18 is exactly 0.009.
BEAST_EXPECT(
divide(STAmount{usd, UINT64_C(9'000'000'000'000'000)}, e18Mpt, noIssue()) ==
STAmount(noIssue(), UINT64_C(9'000'000'000'000'000), -18));
}
// No rules (RPC): same as post-amendment.
BEAST_EXPECT(amountFromQuality(getRate(dust, largeMpt)) == trueRate);
BEAST_EXPECT(amountFromQuality(getRate(one, hugeMpt)) == hugeMptRate);
BEAST_EXPECT(amountFromQuality(getRate(one, hugeXrp)) == hugeXrpRate);
}
void
run() override
{
@@ -1352,6 +1479,7 @@ public:
testCanAddIOU();
testCanAddMPT();
testMPTRateRounding();
testMPTGetRate();
testCanSubtractXRP();
testCanSubtractIOU();
testCanSubtractMPT();
+37 -24
View File
@@ -149,33 +149,34 @@ public:
auto const big = MPT{gw.id(), 1};
auto const usd = iouGw["USD"];
// This metadata represents a partial MPT/IOU offer fill whose deltas
// make divide(deltaGets, deltaPays) overflow before MPTokensV2 skips
// the unrepresentable book-change rate.
STObject finalFields = STObject::makeInnerObject(sfFinalFields);
finalFields.setFieldU32(sfSequence, 1);
finalFields.setFieldAmount(sfTakerGets, big(1'800'000'000'000'000'000ull));
finalFields.setFieldAmount(sfTakerPays, usd(9));
// Metadata for a partial MPT/IOU offer fill. The rate is
// divide(deltaGets, deltaPays): 1.8e18 MPT over `pays` IOU.
auto const test = [&](std::unordered_set<uint256, beast::Uhash<>> const& features,
STAmount const& pays,
std::size_t expectedChanges) {
STObject finalFields = STObject::makeInnerObject(sfFinalFields);
finalFields.setFieldU32(sfSequence, 1);
finalFields.setFieldAmount(sfTakerGets, big(1'800'000'000'000'000'000ull));
finalFields.setFieldAmount(sfTakerPays, pays);
STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
previousFields.setFieldU32(sfSequence, 1);
previousFields.setFieldAmount(sfTakerGets, big(3'600'000'000'000'000'000ull));
previousFields.setFieldAmount(sfTakerPays, usd(18));
STObject previousFields = STObject::makeInnerObject(sfPreviousFields);
previousFields.setFieldU32(sfSequence, 1);
previousFields.setFieldAmount(sfTakerGets, big(3'600'000'000'000'000'000ull));
previousFields.setFieldAmount(sfTakerPays, pays + pays);
STObject modifiedOffer{sfModifiedNode};
modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
modifiedOffer.setFieldObject(sfFinalFields, finalFields);
modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
STObject modifiedOffer{sfModifiedNode};
modifiedOffer.setFieldU16(sfLedgerEntryType, ltOFFER);
modifiedOffer.setFieldObject(sfFinalFields, finalFields);
modifiedOffer.setFieldObject(sfPreviousFields, previousFields);
STArray affectedNodes{sfAffectedNodes};
affectedNodes.pushBack(std::move(modifiedOffer));
STArray affectedNodes{sfAffectedNodes};
affectedNodes.pushBack(std::move(modifiedOffer));
auto metadata = std::make_shared<STObject>(sfTransactionMetaData);
metadata->setFieldArray(sfAffectedNodes, affectedNodes);
auto metadata = std::make_shared<STObject>(sfTransactionMetaData);
metadata->setFieldArray(sfAffectedNodes, affectedNodes);
auto tx = std::make_shared<STTx const>(ttOFFER_CREATE, [](STObject&) {});
auto tx = std::make_shared<STTx const>(ttOFFER_CREATE, [](STObject&) {});
auto const test = [&](std::unordered_set<uint256, beast::Uhash<>> const& features) {
auto ledger = std::make_shared<Ledger>(
2,
NetClock::time_point{},
@@ -198,7 +199,7 @@ public:
auto const result =
xrpl::rpc::computeBookChanges(std::static_pointer_cast<Ledger const>(ledger));
BEAST_EXPECT(result[jss::type] == "bookChanges");
BEAST_EXPECT(result[jss::changes].size() == 0);
BEAST_EXPECT(result[jss::changes].size() == expectedChanges);
}
catch (std::overflow_error const&)
{
@@ -206,8 +207,20 @@ public:
}
};
test(std::unordered_set<uint256, beast::Uhash<>>{});
test(std::unordered_set<uint256, beast::Uhash<>>{featureMPTokensV2});
// Over the smallest IOU the rate is beyond the IOU exponent range:
// the change is skipped.
STAmount const tiny{usd.issue(), STAmount::kMinValue, STAmount::kMinOffset};
// Over 9 USD the rate is 2e17. divide() used to overflow here; with
// Number arithmetic for MPT operands it is reported.
STAmount const nine = usd(9);
for (auto const& features :
{std::unordered_set<uint256, beast::Uhash<>>{},
std::unordered_set<uint256, beast::Uhash<>>{featureMPTokensV2}})
{
test(features, tiny, 0);
test(features, nine, 1);
}
}
// Build a ledger whose transactions are OfferCreates carrying the supplied