diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index ed273120231..2856b96c122 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -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 diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 68205e27e62..27956b92e37 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -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. */ \ diff --git a/include/xrpl/protocol/TER.h b/include/xrpl/protocol/TER.h index 5702b01d1d3..1fad35ab441 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -377,6 +377,7 @@ enum TECcodes : TERUnderlyingType { tecNO_SPONSOR_PERMISSION = 200, tecOUT_OF_GAS = 201, tecBYTECODE_REJECTED = 202, + tecWOULD_CROSS = 203, }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 40edf2239ba..789ce2aa827 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -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, \ diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515dc..41f71625861 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -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) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 18c71b572cd..e63398ed0ae 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -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. diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743aea..0a1c2dc2529 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -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) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd0..42b9335fca5 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -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. */ diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..6203e096dae 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -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 diff --git a/include/xrpl/protocol_autogen/ledger_entries/Offer.h b/include/xrpl/protocol_autogen/ledger_entries/Offer.h index e3539fc1fc0..6d761cb1725 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Offer.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Offer.h @@ -216,6 +216,30 @@ class Offer : public LedgerEntryBase { return this->sle_->isFieldPresent(sfAdditionalBooks); } + + /** + * @brief Get sfMinQuantity (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + 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 @@ class OfferBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfMinQuantity (SoeOptional) + * @return Reference to this builder for method chaining. + */ + OfferBuilder& + setMinQuantity(std::decay_t const& value) + { + object_[sfMinQuantity] = value; + return *this; + } + /** * @brief Build and return the completed Offer wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index 774921d87a8..e1705de11d7 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -148,6 +148,32 @@ class OfferCreate : public TransactionBase { return this->tx_->isFieldPresent(sfDomainID); } + + /** + * @brief Get sfMinQuantity (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + 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 @@ class OfferCreateBuilder : public TransactionBuilderBase return *this; } + /** + * @brief Set sfMinQuantity (SoeOptional) + * @return Reference to this builder for method chaining. + */ + OfferCreateBuilder& + setMinQuantity(std::decay_t const& value) + { + object_[sfMinQuantity] = value; + return *this; + } + /** * @brief Build and return the OfferCreate wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd3017..82d2182f3ea 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -288,6 +288,27 @@ class NoBadOffers 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, diff --git a/include/xrpl/tx/paths/AMMOffer.h b/include/xrpl/tx/paths/AMMOffer.h index 8e7ffedc102..f4ed007c0c2 100644 --- a/include/xrpl/tx/paths/AMMOffer.h +++ b/include/xrpl/tx/paths/AMMOffer.h @@ -76,6 +76,24 @@ class AMMOffer 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 + minQuantity() const + { + return std::nullopt; + } + [[nodiscard]] TAmounts const& amount() const; diff --git a/include/xrpl/tx/paths/BookTip.h b/include/xrpl/tx/paths/BookTip.h index 0a7e3c343e0..e2085a9b8d8 100644 --- a/include/xrpl/tx/paths/BookTip.h +++ b/include/xrpl/tx/paths/BookTip.h @@ -7,6 +7,8 @@ #include #include +#include + namespace xrpl { class Logs; @@ -27,6 +29,13 @@ class BookTip 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 kept_; public: /** @@ -34,6 +43,16 @@ class BookTip */ 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 { diff --git a/include/xrpl/tx/paths/Offer.h b/include/xrpl/tx/paths/Offer.h index 7f368fc2dd4..7d8204f75d8 100644 --- a/include/xrpl/tx/paths/Offer.h +++ b/include/xrpl/tx/paths/Offer.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -121,6 +122,28 @@ class TOffer 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 + minQuantity() const + { + if (!entry_ || !entry_->isFieldPresent(sfMinQuantity)) + return std::nullopt; + return std::min(toAmount(entry_->getFieldAmount(sfMinQuantity)), amounts_.out); + } + [[nodiscard]] Asset const& assetIn() const; [[nodiscard]] Asset const& diff --git a/include/xrpl/tx/paths/OfferStream.h b/include/xrpl/tx/paths/OfferStream.h index 28eefb5d662..7a1d4206df7 100644 --- a/include/xrpl/tx/paths/OfferStream.h +++ b/include/xrpl/tx/paths/OfferStream.h @@ -108,6 +108,17 @@ class TOfferStreamBase 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 { diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp index c6ebe986424..fbc77573bfd 100644 --- a/src/libxrpl/protocol/TER.cpp +++ b/src/libxrpl/protocol/TER.cpp @@ -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."), diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 96820d00bb5..d3df7135797 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -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) { diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index ae218a4cff8..f05ca309cb2 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -226,13 +226,14 @@ class BookStep : public StepImp> // 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 + template std::pair, 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