mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-28 07:48:01 +00:00
featureOfferQualifiers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. */ \
|
||||
|
||||
@@ -377,6 +377,7 @@ enum TECcodes : TERUnderlyingType {
|
||||
tecNO_SPONSOR_PERMISSION = 200,
|
||||
tecOUT_OF_GAS = 201,
|
||||
tecBYTECODE_REJECTED = 202,
|
||||
tecWOULD_CROSS = 203,
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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&
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
883
src/test/app/OfferQualifiers_test.cpp
Normal file
883
src/test/app/OfferQualifiers_test.cpp
Normal 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
|
||||
@@ -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_);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
// {
|
||||
|
||||
Reference in New Issue
Block a user