featureOfferQualifiers

This commit is contained in:
Denis Angell
2026-09-12 05:17:36 -04:00
parent 9403736199
commit 6ad5aae761
27 changed files with 1498 additions and 21 deletions

View File

@@ -28,6 +28,10 @@ Version 3.4.0 is not yet released. These changes are available in the 3.4.0 beta
### Additions in 3.4.0
- `book_offers`, `account_offers`: With the `OfferQualifiers` amendment, an offer entry may now include `all_or_none: true` (the offer carries the `lsfAllOrNone` flag) and/or `min_quantity` (the offer's `MinQuantity` amount). These mark execution-qualified ("contingent") offers that cannot be taken to arbitrary depth; clients should exclude them from quoted/takeable depth. The `OfferCreate` transaction gains the `tfAllOrNone` and `tfPostOnly` flags and an optional `MinQuantity` field, and a marketable `tfPostOnly` offer is rejected with the new `tecWOULD_CROSS` result.
- `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`.
When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present.
- `ledger`: `nftoken_id`, `nftoken_ids`, and `offer_id` are now included in transaction metadata when transactions are expanded (`expand`, or admin-only `full`), matching the `tx`, `account_tx`, and `subscribe` (`transactions` stream) responses. ([#5706](https://github.com/XRPLF/rippled/pull/5706))
### Bugfixes in 3.4.0

View File

@@ -154,7 +154,8 @@ enum LedgerEntryType : std::uint16_t {
LEDGER_OBJECT(Offer, \
LSF_FLAG(lsfPassive, 0x00010000) \
LSF_FLAG(lsfSell, 0x00020000) /* True, offer was placed as a sell. */ \
LSF_FLAG(lsfHybrid, 0x00040000)) /* True, offer is hybrid. */ \
LSF_FLAG(lsfHybrid, 0x00040000) /* True, offer is hybrid. */ \
LSF_FLAG(lsfAllOrNone, 0x00080000)) /* True, offer is all-or-none. */ \
\
LEDGER_OBJECT(RippleState, \
LSF_FLAG(lsfLowReserve, 0x00010000) /* True, if entry counts toward reserve. */ \

View File

@@ -377,6 +377,7 @@ enum TECcodes : TERUnderlyingType {
tecNO_SPONSOR_PERMISSION = 200,
tecOUT_OF_GAS = 201,
tecBYTECODE_REJECTED = 202,
tecWOULD_CROSS = 203,
};
//------------------------------------------------------------------------------

View File

@@ -97,7 +97,9 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TF_FLAG(tfImmediateOrCancel, 0x00020000) \
TF_FLAG(tfFillOrKill, 0x00040000) \
TF_FLAG(tfSell, 0x00080000) \
TF_FLAG(tfHybrid, 0x00100000), \
TF_FLAG(tfHybrid, 0x00100000) \
TF_FLAG(tfAllOrNone, 0x00200000) \
TF_FLAG(tfPostOnly, 0x00400000), \
MASK_ADJ(0)) \
\
TRANSACTION(Payment, \

View File

@@ -15,6 +15,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FEATURE(OfferQualifiers, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo)
XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo)

View File

@@ -240,6 +240,7 @@ LEDGER_ENTRY(ltOFFER, 0x006f, Offer, offer, ({
{sfExpiration, SoeOptional},
{sfDomainID, SoeOptional},
{sfAdditionalBooks, SoeOptional},
{sfMinQuantity, SoeOptional},
}))
/** A ledger object which describes a deposit pre-authorization.

View File

@@ -282,6 +282,7 @@ TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31)
TYPED_SFIELD(sfFeeAmount, AMOUNT, 32)
TYPED_SFIELD(sfMaxFee, AMOUNT, 33)
TYPED_SFIELD(sfFeeAmountDelta, AMOUNT, 34)
TYPED_SFIELD(sfMinQuantity, AMOUNT, 35)
// variable length (common)
TYPED_SFIELD(sfPublicKey, VL, 1)

View File

@@ -141,6 +141,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate,
{sfExpiration, SoeOptional},
{sfOfferSequence, SoeOptional},
{sfDomainID, SoeOptional},
{sfMinQuantity, SoeOptional},
}))
/** This transaction type cancels existing offers to trade one asset for another. */

View File

@@ -110,6 +110,7 @@ JSS(accounts); // in: LedgerEntry, Subscribe, handlers/Ledger
JSS(accounts_proposed); // in: Subscribe, Unsubscribe
JSS(action); //
JSS(active); // out: OverlayImpl
JSS(all_or_none); // out: NetworkOPs
JSS(actor); // in/out: AccountTx
JSS(acquiring); // out: LedgerRequest
JSS(address); // out: PeerImp
@@ -402,6 +403,7 @@ JSS(metaData); //
JSS(metadata); // out: TransactionEntry
JSS(method); // RPC
JSS(methods); //
JSS(min_quantity); // out: NetworkOPs
JSS(metrics); // out: Peers
JSS(min_count); // in: GetCounts
JSS(min_ledger); // in: LedgerCleaner

View File

@@ -216,6 +216,30 @@ public:
{
return this->sle_->isFieldPresent(sfAdditionalBooks);
}
/**
* @brief Get sfMinQuantity (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_AMOUNT::type::value_type>
getMinQuantity() const
{
if (hasMinQuantity())
return this->sle_->at(sfMinQuantity);
return std::nullopt;
}
/**
* @brief Check if sfMinQuantity is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasMinQuantity() const
{
return this->sle_->isFieldPresent(sfMinQuantity);
}
};
/**
@@ -404,6 +428,17 @@ public:
return *this;
}
/**
* @brief Set sfMinQuantity (SoeOptional)
* @return Reference to this builder for method chaining.
*/
OfferBuilder&
setMinQuantity(std::decay_t<typename SF_AMOUNT::type::value_type> const& value)
{
object_[sfMinQuantity] = value;
return *this;
}
/**
* @brief Build and return the completed Offer wrapper.
* @param index The ledger entry index.

View File

@@ -148,6 +148,32 @@ public:
{
return this->tx_->isFieldPresent(sfDomainID);
}
/**
* @brief Get sfMinQuantity (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_AMOUNT::type::value_type>
getMinQuantity() const
{
if (hasMinQuantity())
{
return this->tx_->at(sfMinQuantity);
}
return std::nullopt;
}
/**
* @brief Check if sfMinQuantity is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasMinQuantity() const
{
return this->tx_->isFieldPresent(sfMinQuantity);
}
};
/**
@@ -253,6 +279,17 @@ public:
return *this;
}
/**
* @brief Set sfMinQuantity (SoeOptional)
* @return Reference to this builder for method chaining.
*/
OfferCreateBuilder&
setMinQuantity(std::decay_t<typename SF_AMOUNT::type::value_type> const& value)
{
object_[sfMinQuantity] = value;
return *this;
}
/**
* @brief Build and return the OfferCreate wrapper.
* @param publicKey The public key for signing.

View File

@@ -288,6 +288,27 @@ public:
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const;
};
/**
* @brief Invariant: contingent offers honor their execution floor.
*
* An offer carrying lsfAllOrNone must be consumed in its entirety or not at
* all: a modification that leaves the offer present but reduces its TakerGets
* is a partial fill, which is forbidden. An offer carrying sfMinQuantity must
* never be reduced by less than min(sfMinQuantity, its prior remaining size)
* in one transaction.
*/
class ValidContingentOffers
{
bool bad_ = false;
public:
void
visitEntry(bool, SLE::const_ref, SLE::const_ref);
[[nodiscard]] bool
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const;
};
/**
* @brief Invariant: an escrow entry must take a value between 0 and
* kInitialXRP drops exclusive.
@@ -442,6 +463,7 @@ using InvariantChecks = std::tuple<
NoDeepFreezeTrustLinesWithoutFreeze,
TransfersNotFrozen,
NoBadOffers,
ValidContingentOffers,
NoZeroEscrow,
ValidNewAccountRoot,
ValidNFTokenPage,

View File

@@ -76,6 +76,24 @@ public:
return std::nullopt;
}
/**
* AMM offers are never all-or-none.
*/
[[nodiscard]] bool
isAllOrNone() const
{
return false;
}
/**
* AMM offers never carry a minimum-quantity floor.
*/
[[nodiscard]] std::optional<TOut>
minQuantity() const
{
return std::nullopt;
}
[[nodiscard]] TAmounts<TIn, TOut> const&
amount() const;

View File

@@ -7,6 +7,8 @@
#include <xrpl/protocol/Quality.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <boost/container/flat_set.hpp>
namespace xrpl {
class Logs;
@@ -27,6 +29,13 @@ private:
uint256 index_;
SLE::pointer entry_;
Quality quality_{};
// When set, the next step() leaves the current offer on the book instead
// of deleting it (used to skip a contingent offer without consuming it).
bool keepCurrent_{false};
// Offers kept on the book during this walk. The walk normally advances
// by deleting the consumed tip; a kept offer is not deleted, so step()
// must iterate past every kept entry to reach the rest of its directory.
boost::container::flat_set<uint256> kept_;
public:
/**
@@ -34,6 +43,16 @@ public:
*/
BookTip(ApplyView& view, Book const& book);
/**
* Keep the current offer on the book when advancing past it.
*/
void
keepCurrent()
{
keepCurrent_ = true;
kept_.insert(index_);
}
[[nodiscard]] uint256 const&
dir() const noexcept
{

View File

@@ -10,6 +10,7 @@
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Concepts.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Quality.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
@@ -121,6 +122,28 @@ public:
return entry_->key();
}
/**
* Returns true if the offer is all-or-none: it must be consumed in its
* entirety or not at all (see lsfAllOrNone).
*/
[[nodiscard]] bool
isAllOrNone() const
{
return entry_ && entry_->isFlag(lsfAllOrNone);
}
/**
* Returns the offer's minimum executable quantity (sfMinQuantity,
* denominated in TakerGets), capped at the offer's remaining size.
*/
[[nodiscard]] std::optional<TOut>
minQuantity() const
{
if (!entry_ || !entry_->isFieldPresent(sfMinQuantity))
return std::nullopt;
return std::min(toAmount<TOut>(entry_->getFieldAmount(sfMinQuantity)), amounts_.out);
}
[[nodiscard]] Asset const&
assetIn() const;
[[nodiscard]] Asset const&

View File

@@ -108,6 +108,17 @@ public:
bool
step();
/**
* Keep the current offer on the book when the stream next advances.
* Used to skip a contingent (all-or-none) offer that cannot be taken in
* full, without deleting it.
*/
void
keepCurrentOffer()
{
tip_.keepCurrent();
}
[[nodiscard]] TOut
ownerFunds() const
{

View File

@@ -110,6 +110,7 @@ transResults()
MAKE_ERROR(tecNO_SPONSOR_PERMISSION, "Sponsor has not authorized this transaction."),
MAKE_ERROR(tecOUT_OF_GAS, "The WASM code ran out of gas during execution."),
MAKE_ERROR(tecBYTECODE_REJECTED, "The custom WASM code that was run rejected your transaction."),
MAKE_ERROR(tecWOULD_CROSS, "Post-only offer would cross."),
MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."),
MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."),

View File

@@ -319,6 +319,60 @@ NoBadOffers::finalize(
//------------------------------------------------------------------------------
void
ValidContingentOffers::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
// Only a modification (present before and after) can violate a floor.
// A full consumption deletes the offer; an untouched offer is unchanged.
if (isDelete || !before || !after)
return;
if (after->getType() != ltOFFER)
return;
// A partial fill reduces TakerGets; an all-or-none offer must not be
// reduced while it remains on the ledger.
if (after->isFlag(lsfAllOrNone))
{
if ((*before)[sfTakerGets] != (*after)[sfTakerGets])
bad_ = true;
return;
}
// A minimum-quantity offer may be reduced, but never by less than
// min(sfMinQuantity, its prior remaining size).
if (after->isFieldPresent(sfMinQuantity))
{
STAmount const beforeGets = (*before)[sfTakerGets];
STAmount const afterGets = (*after)[sfTakerGets];
if (beforeGets == afterGets)
return;
STAmount const reduction = beforeGets - afterGets;
STAmount const floor = std::min((*after)[sfMinQuantity], beforeGets);
if (reduction < floor)
bad_ = true;
}
}
bool
ValidContingentOffers::finalize(
STTx const&,
TER const,
XRPAmount const,
ReadView const&,
beast::Journal const& j) const
{
if (bad_)
{
JLOG(j.fatal()) << "Invariant failed: contingent offer reduced below its floor";
return false;
}
return true;
}
//------------------------------------------------------------------------------
void
NoZeroEscrow::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{

View File

@@ -226,13 +226,14 @@ private:
// callback is called with the offer SLE, taker pays, taker gets.
// If callback returns false, don't process any more offers.
// Return the unfunded, bad offers and the number of offers consumed.
template <class Callback>
template <class Callback, class CanFullyConsume>
std::pair<boost::container::flat_set<uint256>, std::uint32_t>
forEachOffer(
PaymentSandbox& sb,
ApplyView& afView,
DebtDirection prevStepDebtDir,
Callback& callback) const;
Callback& callback,
CanFullyConsume&& canFullyConsume) const;
// Offer is either TOffer or AMMOffer
template <template <typename, typename> typename Offer>
@@ -309,6 +310,14 @@ public:
return false;
}
// Payments never consume contingent (all-or-none) offers; those are
// offer-crossing-only.
[[nodiscard]] bool
allowsContingentOffers() const
{
return false;
}
// A payment can look at offers of any quality
[[nodiscard]] bool
checkQualityThreshold(Quality const& quality) const
@@ -399,6 +408,14 @@ public:
{
}
// Offer crossing is the only context that consumes contingent (all-or-none)
// offers, and only when the taker can take them in full.
[[nodiscard]] bool
allowsContingentOffers() const
{
return true;
}
template <template <typename, typename> typename Offer>
bool
limitSelfCrossQuality(
@@ -696,13 +713,14 @@ limitStepOut(
}
template <class TIn, class TOut, class TDerived>
template <class Callback>
template <class Callback, class CanFullyConsume>
std::pair<boost::container::flat_set<uint256>, std::uint32_t>
BookStep<TIn, TOut, TDerived>::forEachOffer(
PaymentSandbox& sb,
ApplyView& afView,
DebtDirection prevStepDir,
Callback& callback) const
Callback& callback,
CanFullyConsume&& canFullyConsume) const
{
// Charge the offer owner, not the sender
// Charge a fee even if the owner is the same as the issuer
@@ -804,8 +822,11 @@ BookStep<TIn, TOut, TDerived>::forEachOffer(
? ownerGives // Offer owner is issuer; they have unlimited funds
: offers.ownerFunds();
// True if the offer cannot deliver its full amount (under-funded).
bool const offerFundsLimited = funds < ownerGives;
// Only if CLOB offer
if (funds < ownerGives)
if (offerFundsLimited)
{
// We already know offer.owner()!=offer.issueOut().account
ownerGives = funds;
@@ -840,6 +861,74 @@ BookStep<TIn, TOut, TDerived>::forEachOffer(
}
}
// Contingent offers (all-or-none, or carrying a minimum-quantity
// floor) participate only on an offer-crossing strand and only when
// the whole offer (all-or-none) or at least the floor (minimum
// quantity) can change hands. When they do not participate there are
// two distinct outcomes, and conflating them lets an under-funded
// contingent offer block the book forever:
//
// * Under-funded: the offer's owner cannot deliver its own
// all-or-none size or minimum-quantity floor, so no taker can
// ever consume it. It is reaped like any other unfunded offer
// (permRmOffer + delete on advance) rather than left resting.
// * Fully fundable, taker too small: the offer can be satisfied,
// the current taker just isn't large enough. It is kept on the
// book so a later, larger taker can take it; the walk trades
// through it to worse-priced liquidity.
//
// A payment strand consumes no contingent offer, but must keep them
// (they remain valid for offer-crossing strands). Resetting ofrQ
// (when nothing has been attempted at this quality) lets the walk
// continue past the skipped offer without deadlocking on it.
auto const minQty = offer.minQuantity();
if (offer.isAllOrNone() || minQty)
{
auto const keep = [&]() {
offers.keepCurrentOffer();
if (!offerAttempted)
ofrQ = std::nullopt;
return true;
};
auto const reap = [&]() {
if (auto const key = offer.key())
offers.permRmOffer(*key);
if (!offerAttempted)
ofrQ = std::nullopt;
return true;
};
if (!static_cast<TDerived const*>(this)->allowsContingentOffers())
return keep();
if (offer.isAllOrNone())
{
// stpAmt already reflects funds-limiting; when limited below
// the offer's full size the owner cannot deliver the whole
// offer, which all-or-none requires.
if (offerFundsLimited)
return reap();
if (!canFullyConsume(stpAmt))
return keep();
}
else // minimum-quantity
{
if (stpAmt.out < *minQty)
// Funds- or issuer-limited below the offer's own floor:
// the owner can never deliver the floor.
return reap();
// The owner can deliver the floor; participate only if the
// taker's demand covers it. Scale the step amounts down to the
// floor and apply the same demand test used for all-or-none.
auto const floorOfrAmt = offer.limitOut(ofrAmt, *minQty, /*roundUp*/ true);
TAmounts const floorStpAmt{
mulRatio(floorOfrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), *minQty};
if (!canFullyConsume(floorStpAmt))
return keep();
}
}
offerAttempted = true;
return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate);
}
@@ -1146,7 +1235,13 @@ BookStep<TIn, TOut, TDerived>::revImp(
return prevStep_->debtDirection(sb, StrandDirection::Reverse);
return DebtDirection::Issues;
}();
auto const r = forEachOffer(sb, afView, prevStepDebtDir, eachOffer);
// A contingent offer can be taken only if the remaining output demand
// covers the tested amounts (the whole offer for all-or-none, the
// floor for minimum-quantity).
auto const canFullyConsume = [&](TAmounts<TIn, TOut> const& stpAmt) {
return stpAmt.out <= remainingOut;
};
auto const r = forEachOffer(sb, afView, prevStepDebtDir, eachOffer, canFullyConsume);
boost::container::flat_set<uint256> const toRm = std::move(std::get<0>(r));
std::uint32_t const offersConsumed = std::get<1>(r);
offersUsed_ = offersConsumed;
@@ -1326,7 +1421,13 @@ BookStep<TIn, TOut, TDerived>::fwdImp(
return prevStep_->debtDirection(sb, StrandDirection::Forward);
return DebtDirection::Issues;
}();
auto const r = forEachOffer(sb, afView, prevStepDebtDir, eachOffer);
// A contingent offer can be taken only if the remaining input demand
// covers the tested amounts (the whole offer for all-or-none, the
// floor for minimum-quantity).
auto const canFullyConsume = [&](TAmounts<TIn, TOut> const& stpAmt) {
return stpAmt.in <= remainingIn;
};
auto const r = forEachOffer(sb, afView, prevStepDebtDir, eachOffer, canFullyConsume);
boost::container::flat_set<uint256> const toRm = std::move(std::get<0>(r));
std::uint32_t const offersConsumed = std::get<1>(r);
offersUsed_ = offersConsumed;

View File

@@ -22,10 +22,14 @@ BookTip::step(beast::Journal j)
{
if (entry_)
{
offerDelete(view_, entry_, j);
// A skipped (kept) contingent offer must stay on the book; only
// delete offers that were stepped past after being consumed.
if (!keepCurrent_)
offerDelete(view_, entry_, j);
entry_ = nullptr;
}
}
keepCurrent_ = false;
for (;;)
{
@@ -42,6 +46,25 @@ BookTip::step(beast::Journal j)
if (dirFirst(view_, *firstPage, dir, di, index_))
{
// Iterate past offers kept on the book earlier in this walk;
// they are not deleted, so they still head their directory.
bool exhausted = false;
while (kept_.contains(index_))
{
if (!dirNext(view_, *firstPage, dir, di, index_))
{
exhausted = true;
break;
}
}
if (exhausted)
{
// Only kept offers remain in this directory: advance the
// cursor past it without deleting anything.
book_ = *firstPage;
continue;
}
dir_ = dir->key();
entry_ = view_.peek(keylet::offer(index_));
quality_ = Quality(getQuality(*firstPage));

View File

@@ -79,13 +79,17 @@ OfferCreate::checkExtraFeatures(PreflightContext const& ctx)
std::uint32_t
OfferCreate::getFlagsMask(PreflightContext const& ctx)
{
// The tfOfferCreateMask is built assuming that PermissionedDEX is
// enabled
if (ctx.rules.enabled(featurePermissionedDEX))
return tfOfferCreateMask;
// If PermissionedDEX is not enabled, add tfHybrid to the mask,
// indicating it is not allowed.
return tfOfferCreateMask | tfHybrid;
// The tfOfferCreateMask is built assuming that all OfferCreate flags are
// enabled; disallowed flags are added back into the mask per-amendment.
std::uint32_t mask = tfOfferCreateMask;
// If PermissionedDEX is not enabled, tfHybrid is not allowed.
if (!ctx.rules.enabled(featurePermissionedDEX))
mask |= tfHybrid;
// If OfferQualifiers is not enabled, the execution-qualifier flags are not
// allowed.
if (!ctx.rules.enabled(featureOfferQualifiers))
mask |= tfAllOrNone | tfPostOnly;
return mask;
}
NotTEC
@@ -112,6 +116,37 @@ OfferCreate::preflight(PreflightContext const& ctx)
return temINVALID_FLAG;
}
// featureOfferQualifiers execution qualifiers.
bool const bAllOrNone(tx.isFlag(tfAllOrNone));
bool const bPostOnly(tx.isFlag(tfPostOnly));
// sfMinQuantity is a field, not a flag, so it is gated explicitly.
if (tx.isFieldPresent(sfMinQuantity) && !ctx.rules.enabled(featureOfferQualifiers))
return temDISABLED;
// Immediate-all-or-none is exactly FillOrKill; use that instead.
if (bAllOrNone && (bImmediateOrCancel || bFillOrKill))
{
JLOG(j.debug()) << "Malformed transaction: AllOrNone with IoC/FoK.";
return temINVALID_FLAG;
}
// Post-only never removes liquidity, so it cannot combine with flags that
// require taking it.
if (bPostOnly && (bImmediateOrCancel || bFillOrKill || tx.isFlag(tfSell)))
{
JLOG(j.debug()) << "Malformed transaction: PostOnly with IoC/FoK/Sell.";
return temINVALID_FLAG;
}
// AllOrNone is MinQuantity pinned to the full size; specifying both is
// redundant and disallowed.
if (bAllOrNone && tx.isFieldPresent(sfMinQuantity))
{
JLOG(j.debug()) << "Malformed transaction: AllOrNone with MinQuantity.";
return temMALFORMED;
}
bool const bHaveExpiration(tx.isFieldPresent(sfExpiration));
if (bHaveExpiration && (tx.getFieldU32(sfExpiration) == 0))
@@ -143,6 +178,18 @@ OfferCreate::preflight(PreflightContext const& ctx)
return temBAD_OFFER;
}
// MinQuantity is a floor on TakerGets: it must be denominated in the
// TakerGets asset, be positive, and not exceed the offered TakerGets.
if (auto const minQty = tx[~sfMinQuantity])
{
if (minQty->asset() != saTakerGets.asset() || *minQty <= beast::kZero ||
*minQty > saTakerGets)
{
JLOG(j.debug()) << "Malformed offer: bad MinQuantity";
return temMALFORMED;
}
}
auto const& uPaysIssuerID = saTakerPays.getIssuer();
auto const& uPaysAsset = saTakerPays.asset();
@@ -484,9 +531,12 @@ OfferCreate::flowCross(
accountID_,
accountID_,
paths,
true, // default path
!ctx_.tx.isFlag(tfFillOrKill), // partial payment
true, // owner pays transfer fee
true, // default path
// AllOrNone crosses all-or-nothing on entry, exactly like
// FillOrKill: either the whole offer crosses now or nothing does
// (and, for AllOrNone, the whole offer then rests).
!(ctx_.tx.isFlag(tfFillOrKill) || ctx_.tx.isFlag(tfAllOrNone)),
true, // owner pays transfer fee
offerCrossing,
threshold,
sendMax,
@@ -650,6 +700,10 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
bool const bFillOrKill(ctx_.tx.isFlag(tfFillOrKill));
bool const bSell(ctx_.tx.isFlag(tfSell));
bool const bHybrid(ctx_.tx.isFlag(tfHybrid));
bool const bAllOrNone(ctx_.tx.isFlag(tfAllOrNone));
// Post-only guarantees the offer never removes liquidity: a marketable
// offer is rejected with tecWOULD_CROSS.
bool const bPostOnly(ctx_.tx.isFlag(tfPostOnly));
auto saTakerPays = ctx_.tx[sfTakerPays];
auto saTakerGets = ctx_.tx[sfTakerGets];
@@ -772,8 +826,29 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
PaymentSandbox psbCancelFlow{&sbCancel};
std::tie(result, placeOffer) = flowCross(psbFlow, psbCancelFlow, takerAmount, domainID);
psbFlow.apply(sb);
// A minimum-quantity offer executes on entry only if at least the
// floor (in TakerGets terms) crosses immediately. A partial cross
// below the floor is discarded — the crossing sandbox is dropped —
// and the whole offer rests instead, still carrying the floor. A
// full cross is never discarded (the floor is a bound on partial
// executions, not on complete ones).
bool restWholeMinQty = false;
if (auto const minQty = ctx_.tx[~sfMinQuantity]; minQty && isTesSuccess(result) &&
placeOffer != takerAmount && placeOffer.in > kZero && placeOffer.out > kZero)
{
STAmount const crossedGets = takerAmount.in - placeOffer.in;
restWholeMinQty = crossedGets < *minQty;
}
if (!restWholeMinQty)
psbFlow.apply(sb);
psbCancelFlow.apply(sbCancel);
if (restWholeMinQty)
{
JLOG(j_.trace()) << "MinQuantity floor not met on entry; resting whole offer";
placeOffer = takerAmount;
}
// We expect the implementation of cross to succeed
// or give a tec.
@@ -808,6 +883,17 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
if (takerAmount != placeOffer)
crossed = true;
// A post-only offer must never remove liquidity. If any crossing
// occurred, the offer was marketable against funded liquidity (the
// flow engine only consumes funded offers), so reject without placing.
// Returning false applies sbCancel, discarding the crossing and
// charging only the fee.
if (bPostOnly && crossed)
{
JLOG(j_.trace()) << "Post-only offer would cross";
return {tecWOULD_CROSS, false};
}
// The offer that we need to place after offer crossing should
// never be negative. If it is, something went very very wrong.
if (placeOffer.in < kZero || placeOffer.out < kZero)
@@ -991,6 +1077,10 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
sleOffer->setFlag(lsfPassive);
if (bSell)
sleOffer->setFlag(lsfSell);
if (bAllOrNone)
sleOffer->setFlag(lsfAllOrNone);
if (auto const minQty = ctx_.tx[~sfMinQuantity])
sleOffer->setFieldAmount(sfMinQuantity, *minQty);
if (domainID)
sleOffer->setFieldH256(sfDomainID, *domainID);

View File

@@ -0,0 +1,883 @@
#include <test/jtx/AMM.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/offer.h>
#include <test/jtx/owners.h>
#include <test/jtx/pay.h>
#include <test/jtx/sendmax.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/to_string.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
namespace xrpl::test {
// Tests for featureOfferQualifiers: all-or-none (tfAllOrNone), minimum
// quantity (sfMinQuantity), and strict post-only (tfPostOnly).
class OfferQualifiers_test : public beast::unit_test::Suite
{
// Read the flags of an account's offer at a given sequence.
static std::uint32_t
offerFlags(jtx::Env& env, jtx::Account const& acct, std::uint32_t seq)
{
auto const sle = env.le(keylet::offer(acct.id(), SeqProxy::rawSequence(seq)));
return sle ? sle->getFieldU32(sfFlags) : 0;
}
// ---- Group A: preflight validation ----
void
testAmendmentGate()
{
testcase("amendment gate");
using namespace jtx;
Env env{*this, testableAmendments() - featureOfferQualifiers};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(10'000), alice);
env.close();
env(pay(gw, alice, USD(1'000)));
env.close();
// The qualifier flags are not allowed without the amendment.
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(temINVALID_FLAG));
env(offer(alice, XRP(100), USD(100), tfPostOnly), Ter(temINVALID_FLAG));
}
void
testFlagCombos()
{
testcase("invalid flag combinations");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(10'000), alice);
env.close();
env(pay(gw, alice, USD(1'000)));
env.close();
// AllOrNone is immediate-or-rest, never immediate-or-cancel; combining
// with IoC/FoK is malformed (immediate-AON is exactly FoK).
env(offer(alice, XRP(100), USD(100), tfAllOrNone | tfFillOrKill), Ter(temINVALID_FLAG));
env(offer(alice, XRP(100), USD(100), tfAllOrNone | tfImmediateOrCancel),
Ter(temINVALID_FLAG));
// Post-only never removes liquidity; it cannot combine with flags that
// require taking it.
env(offer(alice, XRP(100), USD(100), tfPostOnly | tfImmediateOrCancel),
Ter(temINVALID_FLAG));
env(offer(alice, XRP(100), USD(100), tfPostOnly | tfFillOrKill), Ter(temINVALID_FLAG));
env(offer(alice, XRP(100), USD(100), tfPostOnly | tfSell), Ter(temINVALID_FLAG));
}
// ---- Group B: AON entry (fill-whole-or-rest-whole) ----
void
testAonEntryRestsWholeWhenNoLiquidity()
{
testcase("AON rests whole with no liquidity");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(10'000), alice);
env.close();
env(pay(gw, alice, USD(1'000)));
env.close();
auto const seq = env.seq(alice);
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
// The resting offer carries the all-or-none ledger flag.
BEAST_EXPECT(offerFlags(env, alice, seq) & lsfAllOrNone);
}
void
testAonEntryFullyCrosses()
{
testcase("AON fully crosses when liquidity suffices");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// bob rests generous liquidity: gives 100 XRP, wants only 80 USD.
env(offer(bob, USD(80), XRP(100)));
env.close();
// alice's AON wants 100 XRP for 100 USD: fully crosses, nothing rests.
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 0), offers(bob, 0));
}
void
testAonEntryNoPartialFill()
{
testcase("AON never partially fills on entry");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// Only 50 XRP of (clearly marketable) liquidity, less than alice's 100.
env(offer(bob, USD(40), XRP(50)));
env.close();
// alice's AON cannot fully fill, so it crosses nothing and rests whole.
auto const aliceBefore = env.balance(alice, USD);
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 1));
// No funds moved: alice paid no USD.
BEAST_EXPECT(env.balance(alice, USD) == aliceBefore);
}
// ---- Group C: strict post-only ----
void
testPostOnlyRejectsMarketable()
{
testcase("post-only rejects a marketable offer");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// bob rests clearly-crossable liquidity (100 XRP for 80 USD).
env(offer(bob, USD(80), XRP(100)));
env.close();
// alice's post-only would cross bob, so it is rejected and not placed.
env(offer(alice, XRP(100), USD(100), tfPostOnly), Ter(tecWOULD_CROSS));
env.close();
env.require(offers(alice, 0), offers(bob, 1));
}
void
testPostOnlyRestsWhenPassive()
{
testcase("post-only rests when non-marketable");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// bob wants 120 USD for 100 XRP — clearly worse than alice will pay.
env(offer(bob, USD(120), XRP(100)));
env.close();
// alice pays only 100 USD for 100 XRP, so she does not cross bob: rests.
env(offer(alice, XRP(100), USD(100), tfPostOnly), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 1));
}
// ---- Group D: consumption ----
//
// A resting AON offer must be consumed in full or not at all. A taker whose
// demand is smaller than the AON offer's size must trade through it (leave
// it untouched), never partially consume it.
void
testRestingAonSkippedBySmallTaker()
{
testcase("resting AON is skipped by a too-small taker");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// alice rests a 100-XRP AON offer (gives 100 XRP, wants 100 USD).
env(offer(alice, USD(100), XRP(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
// bob only wants 50 XRP — less than alice's all-or-none size, but at a
// clearly-marketable price (60 USD). He must not partially consume her
// offer; both rest untouched.
env(offer(bob, XRP(50), USD(60)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 1));
}
// A taker must trade *through* a too-big AON at the best price to reach a
// worse-priced divisible offer (validates the ofrQ-reset / no-deadlock).
void
testTradeThroughAonToWorseOffer()
{
testcase("trade through a too-big AON to a worse offer");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const carol = Account{"carol"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, carol, bob);
env.close();
env.trust(USD(10'000), alice, carol, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, carol, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// alice's AON sits at the best price (100 XRP for 100 USD = 1.0).
env(offer(alice, USD(100), XRP(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
// carol rests a worse-priced divisible offer (50 XRP for 55 USD = 1.1).
env(offer(carol, USD(55), XRP(50)), Ter(tesSUCCESS));
env.close();
auto const aliceBefore = env.balance(alice, USD);
// bob wants 50 XRP and will pay up to 120 USD. He cannot take alice's
// 100-XRP AON in full, so he trades through it and takes carol's 50.
env(offer(bob, XRP(50), USD(120)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(carol, 0), offers(bob, 0));
// alice's AON was untouched (no funds moved).
BEAST_EXPECT(env.balance(alice, USD) == aliceBefore);
}
// Build an OfferCreate carrying sfMinQuantity (denominated in TakerGets).
static json::Value
offerWithMinQty(
jtx::Account const& account,
STAmount const& takerPays,
STAmount const& takerGets,
STAmount const& minQty)
{
auto jv = jtx::offer(account, takerPays, takerGets);
jv[sfMinQuantity.jsonName] = minQty.getJson(JsonOptions::Values::None);
return jv;
}
// ---- Group E: MinQty preflight ----
void
testMinQtyPreflight()
{
testcase("MinQuantity preflight");
using namespace jtx;
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
{
// The field is not allowed without the amendment.
Env env{*this, testableAmendments() - featureOfferQualifiers};
env.fund(XRP(10'000), gw, alice);
env.close();
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(50)), Ter(temDISABLED));
}
Env env{*this, testableAmendments()};
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(10'000), alice);
env.close();
env(pay(gw, alice, USD(1'000)));
env.close();
// AllOrNone is MinQuantity pinned to the full size; both is malformed.
{
auto jv = offerWithMinQty(alice, USD(100), XRP(100), XRP(50));
jv[jss::Flags] = tfAllOrNone;
env(jv, Ter(temMALFORMED));
}
// The floor must be denominated in the TakerGets asset.
env(offerWithMinQty(alice, USD(100), XRP(100), USD(50)), Ter(temMALFORMED));
// The floor must be positive and no larger than TakerGets.
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(0)), Ter(temMALFORMED));
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(101)), Ter(temMALFORMED));
}
// ---- Group F: MinQty entry (cross at least the floor or rest whole) ----
void
testMinQtyEntryRestsWholeBelowFloor()
{
testcase("MinQty entry rests whole when below-floor crosses");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// bob's resting offer can absorb only 30 XRP of alice's 100.
env(offer(bob, XRP(30), USD(30)), Ter(tesSUCCESS));
env.close();
auto const aliceUsdBefore = env.balance(alice, USD);
auto const seq = env.seq(alice);
// alice gives 100 XRP with a 50-XRP floor: only 30 is immediately
// obtainable, so nothing executes and the whole offer rests.
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(50)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 1));
BEAST_EXPECT(env.balance(alice, USD) == aliceUsdBefore);
auto const sle = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(seq)));
if (BEAST_EXPECT(sle))
{
BEAST_EXPECT(sle->getFieldAmount(sfTakerGets) == XRP(100));
BEAST_EXPECT(sle->getFieldAmount(sfMinQuantity) == XRP(50));
}
}
void
testMinQtyEntryPartialFillAboveFloor()
{
testcase("MinQty entry partial-fills above the floor");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// bob's resting offer absorbs 60 XRP — above alice's 50-XRP floor.
env(offer(bob, XRP(60), USD(60)), Ter(tesSUCCESS));
env.close();
auto const seq = env.seq(alice);
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(50)), Ter(tesSUCCESS));
env.close();
// 60 crossed; the 40-XRP remainder rests, still carrying the floor.
env.require(offers(alice, 1), offers(bob, 0));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'060));
auto const sle = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(seq)));
if (BEAST_EXPECT(sle))
{
BEAST_EXPECT(sle->getFieldAmount(sfTakerGets) == XRP(40));
BEAST_EXPECT(sle->getFieldAmount(sfMinQuantity) == XRP(50));
}
}
// ---- Group G: MinQty resting consumption ----
void
testRestingMinQtySkippedBelowFloor()
{
testcase("resting MinQty is skipped by a below-floor taker");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
// alice rests 100 XRP with a 50-XRP floor.
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(50)), Ter(tesSUCCESS));
env.close();
// bob wants only 30 XRP at a marketable price: below the floor, so
// alice's offer is skipped and bob's offer rests.
env(offer(bob, XRP(30), USD(36)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 1));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'000));
}
void
testRestingMinQtyFillsAtFloorAndRemainderKeepsFloor()
{
testcase("resting MinQty fills at/above floor; remainder keeps floor");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const carol = Account{"carol"};
auto const dan = Account{"dan"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob, carol, dan);
env.close();
env.trust(USD(10'000), alice, bob, carol, dan);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env(pay(gw, carol, USD(1'000)));
env(pay(gw, dan, USD(1'000)));
env.close();
auto const seq = env.seq(alice);
env(offerWithMinQty(alice, USD(100), XRP(100), XRP(50)), Ter(tesSUCCESS));
env.close();
// bob takes exactly the floor: fills 50, remainder 50 rests.
env(offer(bob, XRP(50), USD(50)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(bob, 0));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'050));
auto const sle = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(seq)));
if (BEAST_EXPECT(sle))
{
BEAST_EXPECT(sle->getFieldAmount(sfTakerGets) == XRP(50));
BEAST_EXPECT(sle->getFieldAmount(sfMinQuantity) == XRP(50));
}
// carol wants 30 — below the remainder's floor (min(50, 50)): skipped.
env(offer(carol, XRP(30), USD(36)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1), offers(carol, 1));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'050));
// dan takes the whole 50-XRP remainder: the offer is consumed. (His
// demand also sweeps carol's resting 30-XRP bid first, so he asks for
// 80 in total.)
env(offer(dan, XRP(80), USD(96)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 0));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'100));
}
// An under-funded resting AON offer can never deliver its full size, so a
// crosser reaps it (like any unfunded offer) rather than leaving it to
// block the book. This is the anti-DoS property: a partially-funded
// contingent offer is not kept forever.
void
testRestingAonUnderfundedIsReaped()
{
testcase("under-funded resting AON is reaped, not kept");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
// alice holds only 50 USD but posts a 100-USD all-or-none offer:
// partially funded, so it survives the zero-funded reap but can never
// satisfy its own all-or-none size.
env(pay(gw, alice, USD(50)));
env(pay(gw, bob, USD(1'000)));
env.close();
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
// bob's demand (100 USD) covers alice's full size, but she cannot
// deliver it. Her offer is reaped; bob crosses nothing and rests.
env(offer(bob, USD(100), XRP(100)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 0), offers(bob, 1));
BEAST_EXPECT(env.balance(alice, USD) == USD(50));
}
void
testRestingMinQtyUnderfundedIsReaped()
{
testcase("under-funded resting MinQty is reaped, not kept");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, bob);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
// alice holds 30 USD, below her offer's 50-USD floor.
env(pay(gw, alice, USD(30)));
env(pay(gw, bob, USD(1'000)));
env.close();
env(offerWithMinQty(alice, XRP(100), USD(100), USD(50)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
// alice can fund only 30 USD — below her own floor — so no fill can
// satisfy it. The crosser reaps it.
env(offer(bob, USD(100), XRP(100)), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 0), offers(bob, 1));
BEAST_EXPECT(env.balance(alice, USD) == USD(30));
}
// ---- Group H: security / engine-scope interactions ----
void
testPostOnlyIgnoresUnfundedSpoof()
{
testcase("post-only ignores unfunded spoof offers");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const carol = Account{"carol"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, carol);
env.close();
env.trust(USD(10'000), alice, carol);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, carol, USD(100)));
env.close();
// carol spoofs the book: she rests a better-priced offer giving USD,
// then moves the USD away, leaving the offer unfunded.
env(offer(carol, XRP(50), USD(100)), Ter(tesSUCCESS));
env.close();
env(pay(carol, gw, USD(100)));
env.close();
// alice's post-only would cross carol's price — but only against
// funded liquidity. The spoof is inert; alice's offer rests.
env(offer(alice, USD(100), XRP(100), tfPostOnly), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
}
void
testAonInvisibleToPayment()
{
testcase("contingent offers are invisible to payment strands");
using namespace jtx;
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const carol = Account{"carol"};
auto const USD = gw["USD"];
auto setup = [&](Env& env) {
env.fund(XRP(10'000), gw, alice, bob, carol);
env.close();
env.trust(USD(10'000), alice, bob, carol);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, bob, USD(1'000)));
env.close();
};
{
// Control: a plain resting offer routes bob's cross-currency
// payment (bob pays USD, carol receives XRP via alice's offer).
Env env{*this, testableAmendments()};
setup(env);
env(offer(alice, USD(100), XRP(100)), Ter(tesSUCCESS));
env.close();
env(pay(bob, carol, XRP(50)), Sendmax(USD(60)), Ter(tesSUCCESS));
env.close();
}
{
// The same book with an AON offer: a payment strand never
// consumes contingent offers, even when the size would fit.
Env env{*this, testableAmendments()};
setup(env);
env(offer(alice, USD(100), XRP(100), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env(pay(bob, carol, XRP(50)), Sendmax(USD(60)), Ter(tecPATH_PARTIAL));
env.close();
env.require(offers(alice, 1));
}
}
void
testSellAonEntry()
{
testcase("tfSell composes with AON");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice);
env.close();
env.trust(USD(10'000), alice);
env.close();
env(pay(gw, alice, USD(1'000)));
env.close();
// With no liquidity a sell-AON rests whole, carrying both flags.
auto const seq = env.seq(alice);
env(offer(alice, XRP(100), USD(100), tfAllOrNone | tfSell), Ter(tesSUCCESS));
env.close();
env.require(offers(alice, 1));
auto const flags = offerFlags(env, alice, seq);
BEAST_EXPECT(flags & lsfAllOrNone);
BEAST_EXPECT(flags & lsfSell);
}
void
testAonCrossesAmm()
{
testcase("AON crosses AMM liquidity; too-big AON rests whole");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
env.fund(XRP(1'000'000), gw, alice, bob);
env.close();
env.trust(USD(1'000'000), alice, bob);
env.close();
env(pay(gw, alice, USD(100'000)));
env(pay(gw, bob, USD(100'000)));
env.close();
// A deep pool: 100k XRP / 100k USD, mid price 1.0.
AMM amm(env, alice, XRP(100'000), USD(100'000));
// bob's small AON is fully satisfiable from the pool: it crosses in
// full and nothing rests.
env(offer(bob, XRP(100), USD(102), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(bob, 0));
// bob's over-priced AON cannot fully cross within its limit quality:
// it executes nothing and rests whole.
auto const seq = env.seq(bob);
env(offer(bob, XRP(50'000), USD(50'000), tfAllOrNone), Ter(tesSUCCESS));
env.close();
env.require(offers(bob, 1));
auto const sle = env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(seq)));
if (BEAST_EXPECT(sle))
BEAST_EXPECT(sle->getFieldAmount(sfTakerGets) == USD(50'000));
}
// A MinQty offer sitting in one leg of an autobridged (IOU→XRP→IOU)
// offer-crossing strand: it is consumed through a multi-step strand (the
// forward pass), which is the highest-risk path for a sub-floor rounding
// fill. Confirm it either fills at/above its floor or is skipped whole —
// never partially consumed below the floor (which would trip
// ValidContingentOffers on an innocent taker).
void
testMinQtyAutobridge()
{
testcase("MinQty offer in an autobridged crossing leg");
using namespace jtx;
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const USD = gw["USD"];
auto const EUR = gw["EUR"];
auto setup = [&](Env& env) {
env.fund(XRP(100'000), gw, alice, bob);
env.close();
env.trust(USD(100'000), alice, bob);
env.trust(EUR(100'000), alice, bob);
env.close();
env(pay(gw, alice, USD(10'000)));
env(pay(gw, alice, EUR(10'000)));
env(pay(gw, bob, USD(10'000)));
env.close();
// Bridge legs, both 1:1: USD→XRP and XRP→EUR. The XRP→EUR leg
// carries a 50-EUR minimum. No direct USD/EUR book, so bob can
// only fill by autobridging through XRP.
env(offer(alice, USD(100), XRP(100)), Ter(tesSUCCESS));
env(offerWithMinQty(alice, XRP(100), EUR(100), EUR(50)), Ter(tesSUCCESS));
env.close();
};
{
// bob wants 100 EUR — above the 50 floor — so the bridge fully
// engages and the MinQty leg is consumed in full.
Env env{*this, testableAmendments()};
setup(env);
env(offer(bob, EUR(100), USD(100)), Ter(tesSUCCESS));
env.close();
env.require(offers(bob, 0));
BEAST_EXPECT(env.balance(bob, EUR) == EUR(100));
}
{
// bob wants only 30 EUR — below the floor — so the MinQty leg is
// skipped, the bridge cannot deliver, and bob's offer rests
// untouched. alice's legs remain; no sub-floor fill occurs.
Env env{*this, testableAmendments()};
setup(env);
env(offer(bob, EUR(30), USD(30)), Ter(tesSUCCESS));
env.close();
env.require(offers(bob, 1), offers(alice, 2));
BEAST_EXPECT(env.balance(bob, EUR) == EUR(0));
}
{
// bob wants exactly 50 EUR — the floor boundary — the leg is
// consumed to exactly its floor and the remainder rests.
Env env{*this, testableAmendments()};
setup(env);
env(offer(bob, EUR(50), USD(50)), Ter(tesSUCCESS));
env.close();
env.require(offers(bob, 0));
BEAST_EXPECT(env.balance(bob, EUR) == EUR(50));
}
}
// ---- Group I: RPC ----
void
testBookOffersMarkers()
{
testcase("book_offers marks contingent entries");
using namespace jtx;
Env env{*this, testableAmendments()};
auto const gw = Account{"gw"};
auto const alice = Account{"alice"};
auto const carol = Account{"carol"};
auto const USD = gw["USD"];
env.fund(XRP(10'000), gw, alice, carol);
env.close();
env.trust(USD(10'000), alice, carol);
env.close();
env(pay(gw, alice, USD(1'000)));
env(pay(gw, carol, USD(1'000)));
env.close();
// Both give USD and want XRP: same book, distinct qualifiers.
env(offer(alice, XRP(100), USD(100), tfAllOrNone), Ter(tesSUCCESS));
env(offerWithMinQty(carol, XRP(100), USD(100), USD(40)), Ter(tesSUCCESS));
env.close();
// book_offers for the USD-out book (taker pays XRP, gets USD).
json::Value jvParams;
jvParams[jss::ledger_index] = "current";
jvParams[jss::taker_pays][jss::currency] = "XRP";
jvParams[jss::taker_gets][jss::currency] = "USD";
jvParams[jss::taker_gets][jss::issuer] = gw.human();
auto const result = env.rpc("json", "book_offers", to_string(jvParams))[jss::result];
if (BEAST_EXPECT(result.isMember(jss::offers) && result[jss::offers].size() == 2))
{
bool sawAon = false;
bool sawMinQty = false;
for (auto const& jvOffer : result[jss::offers])
{
if (jvOffer.isMember(jss::all_or_none) && jvOffer[jss::all_or_none] == true)
sawAon = true;
if (jvOffer.isMember(jss::min_quantity))
sawMinQty = true;
}
BEAST_EXPECT(sawAon);
BEAST_EXPECT(sawMinQty);
}
}
public:
void
run() override
{
testAmendmentGate();
testFlagCombos();
testAonEntryRestsWholeWhenNoLiquidity();
testAonEntryFullyCrosses();
testAonEntryNoPartialFill();
testPostOnlyRejectsMarketable();
testPostOnlyRestsWhenPassive();
testRestingAonSkippedBySmallTaker();
testTradeThroughAonToWorseOffer();
testRestingAonUnderfundedIsReaped();
testRestingMinQtyUnderfundedIsReaped();
testMinQtyPreflight();
testMinQtyEntryRestsWholeBelowFloor();
testMinQtyEntryPartialFillAboveFloor();
testRestingMinQtySkippedBelowFloor();
testRestingMinQtyFillsAtFloorAndRemainderKeepsFloor();
testPostOnlyIgnoresUnfundedSpoof();
testAonInvisibleToPayment();
testSellAonEntry();
testAonCrossesAmm();
testMinQtyAutobridge();
testBookOffersMarkers();
}
};
BEAST_DEFINE_TESTSUITE_PRIO(OfferQualifiers, app, xrpl, 2);
} // namespace xrpl::test

View File

@@ -4,6 +4,7 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/offer.h>
#include <test/jtx/pay.h>
#include <test/jtx/token.h>
#include <test/jtx/trust.h>
@@ -1559,6 +1560,79 @@ class InvariantsMisc_test : public InvariantsBase
}
}
void
testValidContingentOffers()
{
using namespace test::jtx;
testcase << "valid contingent offers";
Account const gw{"gw"};
auto const USD = gw["USD"];
std::uint32_t seq = 0;
// An all-or-none offer must be consumed whole or not at all: reducing
// its TakerGets while it remains on the ledger is a partial fill.
// Halve both sides so quality/directory stay consistent and only the
// contingent-floor invariant fires.
doInvariantCheck(
{{"contingent offer reduced below its floor"}},
[&](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::offer(a1.id(), SeqProxy::rawSequence(seq)));
if (!sle)
return false;
sle->setFieldAmount(sfTakerGets, XRP(50));
sle->setFieldAmount(sfTakerPays, USD(50));
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttOFFER_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[&](Account const& a1, Account const&, Env& env) {
env.fund(XRP(10'000), gw);
env.close();
env.trust(USD(10'000), a1);
env.close();
env(pay(gw, a1, USD(1'000)));
env.close();
seq = env.seq(a1);
env(offer(a1, USD(100), XRP(100), tfAllOrNone));
env.close();
return true;
});
// A minimum-quantity offer must never be reduced by less than its
// floor. Reduce TakerGets by 30 against a 50 floor.
doInvariantCheck(
{{"contingent offer reduced below its floor"}},
[&](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::offer(a1.id(), SeqProxy::rawSequence(seq)));
if (!sle)
return false;
sle->setFieldAmount(sfTakerGets, USD(70));
sle->setFieldAmount(sfTakerPays, XRP(70));
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttOFFER_CREATE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[&](Account const& a1, Account const&, Env& env) {
env.fund(XRP(10'000), gw);
env.close();
env.trust(USD(10'000), a1);
env.close();
env(pay(gw, a1, USD(1'000)));
env.close();
seq = env.seq(a1);
auto jv = offer(a1, XRP(100), USD(100));
jv[sfMinQuantity.jsonName] = USD(50).value().getJson(JsonOptions::Values::None);
env(jv);
env.close();
return true;
});
}
void
run() override
{
@@ -1569,6 +1643,7 @@ class InvariantsMisc_test : public InvariantsBase
testXRPBalanceCheck();
testTransactionFeeCheck();
testNoBadOffers();
testValidContingentOffers();
testValidNewAccountRoot();
testNoModifiedUnmodifiableFields();
testInvariantOverwrite(all_);

View File

@@ -32,6 +32,7 @@ TEST(OfferTests, BuilderSettersRoundTrip)
auto const expirationValue = canonical_UINT32();
auto const domainIDValue = canonical_UINT256();
auto const additionalBooksValue = canonical_ARRAY();
auto const minQuantityValue = canonical_AMOUNT();
OfferBuilder builder{
accountValue,
@@ -48,6 +49,7 @@ TEST(OfferTests, BuilderSettersRoundTrip)
builder.setExpiration(expirationValue);
builder.setDomainID(domainIDValue);
builder.setAdditionalBooks(additionalBooksValue);
builder.setMinQuantity(minQuantityValue);
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
@@ -136,6 +138,14 @@ TEST(OfferTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasAdditionalBooks());
}
{
auto const& expected = minQuantityValue;
auto const actualOpt = entry.getMinQuantity();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfMinQuantity");
EXPECT_TRUE(entry.hasMinQuantity());
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
@@ -161,6 +171,7 @@ TEST(OfferTests, BuilderFromSleRoundTrip)
auto const expirationValue = canonical_UINT32();
auto const domainIDValue = canonical_UINT256();
auto const additionalBooksValue = canonical_ARRAY();
auto const minQuantityValue = canonical_AMOUNT();
auto sle = std::make_shared<SLE>(Offer::entryType, index);
@@ -176,6 +187,7 @@ TEST(OfferTests, BuilderFromSleRoundTrip)
sle->at(sfExpiration) = expirationValue;
sle->at(sfDomainID) = domainIDValue;
sle->setFieldArray(sfAdditionalBooks, additionalBooksValue);
sle->at(sfMinQuantity) = minQuantityValue;
OfferBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
@@ -315,6 +327,19 @@ TEST(OfferTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfAdditionalBooks");
}
{
auto const& expected = minQuantityValue;
auto const fromSleOpt = entryFromSle.getMinQuantity();
auto const fromBuilderOpt = entryFromBuilder.getMinQuantity();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfMinQuantity");
expectEqualField(expected, *fromBuilderOpt, "sfMinQuantity");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
@@ -391,5 +416,7 @@ TEST(OfferTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getDomainID().has_value());
EXPECT_FALSE(entry.hasAdditionalBooks());
EXPECT_FALSE(entry.getAdditionalBooks().has_value());
EXPECT_FALSE(entry.hasMinQuantity());
EXPECT_FALSE(entry.getMinQuantity().has_value());
}
}

View File

@@ -34,6 +34,7 @@ TEST(TransactionsOfferCreateTests, BuilderSettersRoundTrip)
auto const expirationValue = canonical_UINT32();
auto const offerSequenceValue = canonical_UINT32();
auto const domainIDValue = canonical_UINT256();
auto const minQuantityValue = canonical_AMOUNT();
OfferCreateBuilder builder{
accountValue,
@@ -47,6 +48,7 @@ TEST(TransactionsOfferCreateTests, BuilderSettersRoundTrip)
builder.setExpiration(expirationValue);
builder.setOfferSequence(offerSequenceValue);
builder.setDomainID(domainIDValue);
builder.setMinQuantity(minQuantityValue);
auto tx = builder.build(publicKey, secretKey);
@@ -100,6 +102,14 @@ TEST(TransactionsOfferCreateTests, BuilderSettersRoundTrip)
EXPECT_TRUE(tx.hasDomainID());
}
{
auto const& expected = minQuantityValue;
auto const actualOpt = tx.getMinQuantity();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMinQuantity should be present";
expectEqualField(expected, *actualOpt, "sfMinQuantity");
EXPECT_TRUE(tx.hasMinQuantity());
}
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
@@ -121,6 +131,7 @@ TEST(TransactionsOfferCreateTests, BuilderFromStTxRoundTrip)
auto const expirationValue = canonical_UINT32();
auto const offerSequenceValue = canonical_UINT32();
auto const domainIDValue = canonical_UINT256();
auto const minQuantityValue = canonical_AMOUNT();
// Build an initial transaction
OfferCreateBuilder initialBuilder{
@@ -134,6 +145,7 @@ TEST(TransactionsOfferCreateTests, BuilderFromStTxRoundTrip)
initialBuilder.setExpiration(expirationValue);
initialBuilder.setOfferSequence(offerSequenceValue);
initialBuilder.setDomainID(domainIDValue);
initialBuilder.setMinQuantity(minQuantityValue);
auto initialTx = initialBuilder.build(publicKey, secretKey);
@@ -185,6 +197,13 @@ TEST(TransactionsOfferCreateTests, BuilderFromStTxRoundTrip)
expectEqualField(expected, *actualOpt, "sfDomainID");
}
{
auto const& expected = minQuantityValue;
auto const actualOpt = rebuiltTx.getMinQuantity();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMinQuantity should be present";
expectEqualField(expected, *actualOpt, "sfMinQuantity");
}
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
@@ -250,6 +269,8 @@ TEST(TransactionsOfferCreateTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(tx.getOfferSequence().has_value());
EXPECT_FALSE(tx.hasDomainID());
EXPECT_FALSE(tx.getDomainID().has_value());
EXPECT_FALSE(tx.hasMinQuantity());
EXPECT_FALSE(tx.getMinQuantity().has_value());
}
}

View File

@@ -4924,6 +4924,14 @@ NetworkOPsImp::getBookPage(
json::Value jvOffer = sleOffer->getJson(JsonOptions::Values::None);
// Contingent offers cannot be taken to arbitrary depth, so
// they are marked explicitly: clients must exclude them from
// quoted/takeable depth.
if (sleOffer->isFlag(lsfAllOrNone))
jvOffer[jss::all_or_none] = true;
if (sleOffer->isFieldPresent(sfMinQuantity))
sleOffer->getFieldAmount(sfMinQuantity).setJson(jvOffer[jss::min_quantity]);
STAmount saTakerGetsFunded;
STAmount saOwnerFundsLimit = saOwnerFunds;
Rate offerRate = kParityRate;
@@ -5093,6 +5101,14 @@ NetworkOPsImp::getBookPage(
json::Value jvOffer = sleOffer->getJson(JsonOptions::Values::None);
// Contingent offers cannot be taken to arbitrary depth, so they
// are marked explicitly: clients must exclude them from
// quoted/takeable depth.
if (sleOffer->isFlag(lsfAllOrNone))
jvOffer[jss::all_or_none] = true;
if (sleOffer->isFieldPresent(sfMinQuantity))
sleOffer->getFieldAmount(sfMinQuantity).setJson(jvOffer[jss::min_quantity]);
STAmount saTakerGetsFunded;
STAmount saOwnerFundsLimit = saOwnerFunds;
Rate offerRate = parityRate;

View File

@@ -42,6 +42,13 @@ appendOfferJson(SLE::const_ref offer, json::Value& offers)
obj[jss::quality] = dirRate.getText();
if (offer->isFieldPresent(sfExpiration))
obj[jss::expiration] = offer->getFieldU32(sfExpiration);
// Contingent (execution-qualified) offers cannot be taken to arbitrary
// depth; surface the markers so clients exclude them from quoted depth,
// matching book_offers.
if (offer->isFlag(lsfAllOrNone))
obj[jss::all_or_none] = true;
if (offer->isFieldPresent(sfMinQuantity))
offer->getFieldAmount(sfMinQuantity).setJson(obj[jss::min_quantity]);
};
// {