diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index 724d89b7c6..f33dc67dcb 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -43,7 +43,14 @@ enum ApplyFlags : std::uint32_t { // Transaction shouldn't be applied // Signatures shouldn't be checked - TapDryRun = 0x1000 + TapDryRun = 0x1000, + + // Transaction is being preflighted as the payload of a + // TransactionProposalCreate. Its signatures are collected on-ledger + // afterward, so signature-presence checks (e.g. Batch signer matching) + // are skipped at proposal-creation time (On-Chain Cosigner spec + // §5.3.1.2). + TapProposal = 0x2000 }; constexpr ApplyFlags diff --git a/include/xrpl/ledger/entries/TransactionProposalEntry.h b/include/xrpl/ledger/entries/TransactionProposalEntry.h new file mode 100644 index 0000000000..b7729f7a2b --- /dev/null +++ b/include/xrpl/ledger/entries/TransactionProposalEntry.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +class TransactionProposalEntry : public SLEBase +{ +public: + using Base = SLEBase; + + // Inherit base constructors: adopt an existing SLE, or resolve one from a + // Keylet against the view. + using Base::Base; + + explicit TransactionProposalEntry( + AccountID const& target, + std::uint32_t ticketSequence, + Base::ViewRefType view, + beast::Journal j = beast::Journal{beast::Journal::getNullSink()}) + : Base(keylet::txProposal(target, ticketSequence), view, j) + { + } +}; + +using TransactionProposalEntryR = TransactionProposalEntry; +using TransactionProposalEntryW = TransactionProposalEntry; + +} // namespace xrpl diff --git a/include/xrpl/ledger/helpers/ProposalHelpers.h b/include/xrpl/ledger/helpers/ProposalHelpers.h new file mode 100644 index 0000000000..0a09dfd887 --- /dev/null +++ b/include/xrpl/ledger/helpers/ProposalHelpers.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +#include + +namespace xrpl::proposal { + +/** + * Owner-reserve increments held by a proposal of an ordinary transaction. + */ +constexpr std::uint32_t kProposalOwnerCount = 5; + +/** + * Owner-reserve increments held by a proposal of a Batch transaction. A + * proposed Batch stores up to eight inner transactions plus multi-account + * signatures, so it reserves more than an ordinary proposed transaction. + */ +constexpr std::uint32_t kBatchProposalOwnerCount = 10; + +/** + * Owner-reserve increments held by a proposal of the given transaction. + */ +inline std::uint32_t +proposalOwnerCount(STObject const& proposedTx) +{ + return proposedTx.getFieldU16(sfTransactionType) == ttBATCH ? kBatchProposalOwnerCount + : kProposalOwnerCount; +} + +/** + * Whether the proposed transaction is itself a proposal transaction, which + * would nest one proposal inside another. + * + * TODO: cover ttTRANSACTION_PROPOSAL_SIGN and ttTRANSACTION_PROPOSAL_CANCEL + * once those transactions exist. + */ +inline bool +isProposalTx(STObject const& proposedTx) +{ + return proposedTx.getFieldU16(sfTransactionType) == ttTRANSACTION_PROPOSAL_CREATE; +} + +/** + * Whether the proposed transaction is independently submittable through the + * ordinary multi-sign path: not a nested proposal, not a pseudo-transaction, + * not itself flagged as someone else's inner batch transaction, and — if it + * is a Batch — none of its own inner transactions is a nested proposal or a + * pseudo-transaction either. A Batch inner transaction cannot itself be + * pseudo (preflight0 rejects the pseudo/tfInnerBatchTxn combination + * generically), but that guard lives outside this feature, so it is checked + * again here rather than relied upon. + */ +bool +isValidProposal(STObject const& proposedTx); + +/** + * Whether the proposed transaction carries any signature field. + * + * A proposal is stored in unsigned canonical form; signatures may only ever + * arrive through TransactionProposalSign. Shared by the create-time check and + * the invariant that guards the stored entry, so the two cannot drift apart. + */ +inline bool +hasSignatureField(STObject const& proposedTx) +{ + return proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) || + proposedTx.isFieldPresent(sfBatchSigners) || + proposedTx.isFieldPresent(sfCounterpartySignature) || + proposedTx.isFieldPresent(sfSponsorSignature); +} + +/** + * Whether the proposed transaction's SigningPubKey is present and empty, as + * unsigned canonical form requires. An absent field is not the same as an + * empty one, and a populated one means the payload was already signed. + */ +inline bool +hasEmptySigningPubKey(STObject const& proposedTx) +{ + return proposedTx.isFieldPresent(sfSigningPubKey) && + proposedTx.getFieldVL(sfSigningPubKey).empty(); +} + +} // namespace xrpl::proposal diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 0836cffaf7..abb039a4ef 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -183,6 +183,20 @@ check(uint256 const& key) noexcept } /** @} */ +/** + * A TransactionProposal + */ +/** @{ */ +Keylet +txProposal(AccountID const& target, std::uint32_t ticketSequence) noexcept; + +inline Keylet +txProposal(uint256 const& key) noexcept +{ + return {ltTRANSACTION_PROPOSAL, key}; +} +/** @} */ + /** * A DepositPreauth */ diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515d..eb92107133 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(Cosign, Supported::No, 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 04b390a7a4..e642c1e8c6 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -651,5 +651,24 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ {sfSponseeNode, SoeRequired}, })) +/** A ledger object holding a pending transaction proposal. + + The proposed transaction is stored unsigned in ProposedTransaction, and + signatures accumulate on it over time. It becomes fully signed either from + a single signature by its own Account (or that account's Delegate), or once + the weight collected in its Signers field meets that account's quorum. At + that point the stored transaction is one that anyone may copy and submit. + + \sa keylet::txProposal + */ +LEDGER_ENTRY(ltTRANSACTION_PROPOSAL, 0x0091, TransactionProposal, transaction_proposal, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwner, SoeRequired}, + {sfProposedTransaction, SoeRequired}, + {sfExpiration, SoeRequired}, + {sfOwnerNode, SoeRequired}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743ae..14c86c9f79 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -220,6 +220,7 @@ TYPED_SFIELD(sfLoanID, UINT256, 38) TYPED_SFIELD(sfReferenceHolding, UINT256, 39) TYPED_SFIELD(sfBlindingFactor, UINT256, 40) TYPED_SFIELD(sfObjectID, UINT256, 41) +TYPED_SFIELD(sfProposalID, UINT256, 42) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -360,6 +361,7 @@ TYPED_SFIELD(sfHighSponsor, ACCOUNT, 28) TYPED_SFIELD(sfLowSponsor, ACCOUNT, 29) TYPED_SFIELD(sfCounterpartySponsor, ACCOUNT, 30) TYPED_SFIELD(sfSponsee, ACCOUNT, 31) +TYPED_SFIELD(sfSigningFor, ACCOUNT, 32) // vector of 256-bit TYPED_SFIELD(sfIndexes, VECTOR256, 1, SField::kSmdNever) @@ -421,6 +423,7 @@ UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::kSmdDefault, SField::kNotSigning) UNTYPED_SFIELD(sfSponsorSignature, OBJECT, 38, SField::kSmdDefault, SField::kNotSigning) +UNTYPED_SFIELD(sfProposedTransaction, OBJECT, 39) // array of objects (common) // ARRAY/1 is reserved for end of array diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index cb3d5fd2b1..72ea19309b 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1147,6 +1147,18 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MIRROR_UPDATE, 92, ConfidentialMPTMirrorUpdate, {sfZKProof, SoeRequired}, })) +/** This transaction posts an unsigned transaction on-ledger as a + TransactionProposal, pending multi-signature collection. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 93, TransactionProposalCreate, + ({.amendment = featureCosign}), + ({ + {sfProposedTransaction, SoeRequired}, + {sfExpiration, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h b/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h new file mode 100644 index 0000000000..905139bf37 --- /dev/null +++ b/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h @@ -0,0 +1,242 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::ledger_entries { + +class TransactionProposalBuilder; + +/** + * @brief Ledger Entry: TransactionProposal + * + * Type: ltTRANSACTION_PROPOSAL (0x0091) + * RPC Name: transaction_proposal + * + * Immutable wrapper around SLE providing type-safe field access. + * Use TransactionProposalBuilder to construct new ledger entries. + */ +class TransactionProposal : public LedgerEntryBase +{ +public: + static constexpr LedgerEntryType entryType = ltTRANSACTION_PROPOSAL; + + /** + * @brief Construct a TransactionProposal ledger entry wrapper from an existing SLE object. + * @throws std::runtime_error if the ledger entry type doesn't match. + */ + explicit TransactionProposal(SLE::const_pointer sle) + : LedgerEntryBase(std::move(sle)) + { + // Verify ledger entry type + if (sle_->getType() != entryType) + { + throw std::runtime_error("Invalid ledger entry type for TransactionProposal"); + } + } + + // Ledger entry-specific field getters + + /** + * @brief Get sfPreviousTxnID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getPreviousTxnID() const + { + return this->sle_->at(sfPreviousTxnID); + } + + /** + * @brief Get sfPreviousTxnLgrSeq (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getPreviousTxnLgrSeq() const + { + return this->sle_->at(sfPreviousTxnLgrSeq); + } + + /** + * @brief Get sfOwner (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getOwner() const + { + return this->sle_->at(sfOwner); + } + + /** + * @brief Get sfProposedTransaction (SoeRequired) + * @note This is an untyped field (unknown). + * @return The field value. + */ + [[nodiscard]] + STObject + getProposedTransaction() const + { + return this->sle_->getFieldObject(sfProposedTransaction); + } + + /** + * @brief Get sfExpiration (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getExpiration() const + { + return this->sle_->at(sfExpiration); + } + + /** + * @brief Get sfOwnerNode (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getOwnerNode() const + { + return this->sle_->at(sfOwnerNode); + } +}; + +/** + * @brief Builder for TransactionProposal ledger entries. + * + * Provides a fluent interface for constructing ledger entries with method chaining. + * Uses STObject internally for flexible ledger entry construction. + * Inherits common field setters from LedgerEntryBuilderBase. + */ +class TransactionProposalBuilder : public LedgerEntryBuilderBase +{ +public: + /** + * @brief Construct a new TransactionProposalBuilder with required fields. + * @param previousTxnID The sfPreviousTxnID field value. + * @param previousTxnLgrSeq The sfPreviousTxnLgrSeq field value. + * @param owner The sfOwner field value. + * @param proposedTransaction The sfProposedTransaction field value. + * @param expiration The sfExpiration field value. + * @param ownerNode The sfOwnerNode field value. + */ + TransactionProposalBuilder(std::decay_t const& previousTxnID,std::decay_t const& previousTxnLgrSeq,std::decay_t const& owner,STObject const& proposedTransaction,std::decay_t const& expiration,std::decay_t const& ownerNode) + : LedgerEntryBuilderBase(ltTRANSACTION_PROPOSAL) + { + setPreviousTxnID(previousTxnID); + setPreviousTxnLgrSeq(previousTxnLgrSeq); + setOwner(owner); + setProposedTransaction(proposedTransaction); + setExpiration(expiration); + setOwnerNode(ownerNode); + } + + /** + * @brief Construct a TransactionProposalBuilder from an existing SLE object. + * @param sle The existing ledger entry to copy from. + * @throws std::runtime_error if the ledger entry type doesn't match. + */ + TransactionProposalBuilder(SLE::const_pointer sle) + { + if (sle->at(sfLedgerEntryType) != ltTRANSACTION_PROPOSAL) + { + throw std::runtime_error("Invalid ledger entry type for TransactionProposal"); + } + object_ = *sle; + } + + /** + * @brief Ledger entry-specific field setters + */ + + /** + * @brief Set sfPreviousTxnID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setPreviousTxnID(std::decay_t const& value) + { + object_[sfPreviousTxnID] = value; + return *this; + } + + /** + * @brief Set sfPreviousTxnLgrSeq (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setPreviousTxnLgrSeq(std::decay_t const& value) + { + object_[sfPreviousTxnLgrSeq] = value; + return *this; + } + + /** + * @brief Set sfOwner (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setOwner(std::decay_t const& value) + { + object_[sfOwner] = value; + return *this; + } + + /** + * @brief Set sfProposedTransaction (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setProposedTransaction(STObject const& value) + { + object_.setFieldObject(sfProposedTransaction, value); + return *this; + } + + /** + * @brief Set sfExpiration (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setExpiration(std::decay_t const& value) + { + object_[sfExpiration] = value; + return *this; + } + + /** + * @brief Set sfOwnerNode (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setOwnerNode(std::decay_t const& value) + { + object_[sfOwnerNode] = value; + return *this; + } + + /** + * @brief Build and return the completed TransactionProposal wrapper. + * @param index The ledger entry index. + * @return The constructed ledger entry wrapper. + */ + TransactionProposal + build(uint256 const& index) + { + return TransactionProposal{std::make_shared(std::move(object_), index)}; + } +}; + +} // namespace xrpl::ledger_entries diff --git a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h new file mode 100644 index 0000000000..4da2deed00 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h @@ -0,0 +1,155 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class TransactionProposalCreateBuilder; + +/** + * @brief Transaction: TransactionProposalCreate + * + * Type: ttTRANSACTION_PROPOSAL_CREATE (93) + * Delegable: Delegation::NotDelegable + * Amendment: featureCosign + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use TransactionProposalCreateBuilder to construct new transactions. + */ +class TransactionProposalCreate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttTRANSACTION_PROPOSAL_CREATE; + + /** + * @brief Construct a TransactionProposalCreate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit TransactionProposalCreate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for TransactionProposalCreate"); + } + } + + // Transaction-specific field getters + /** + * @brief Get sfProposedTransaction (SoeRequired) + * @note This is an untyped field. + * @return The field value. + */ + [[nodiscard]] + STObject + getProposedTransaction() const + { + return this->tx_->getFieldObject(sfProposedTransaction); + } + + /** + * @brief Get sfExpiration (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getExpiration() const + { + return this->tx_->at(sfExpiration); + } +}; + +/** + * @brief Builder for TransactionProposalCreate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class TransactionProposalCreateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new TransactionProposalCreateBuilder with required fields. + * @param account The account initiating the transaction. + * @param proposedTransaction The sfProposedTransaction field value. + * @param expiration The sfExpiration field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + TransactionProposalCreateBuilder(SF_ACCOUNT::type::value_type account, + STObject const& proposedTransaction, std::decay_t const& expiration, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttTRANSACTION_PROPOSAL_CREATE, account, sequence, fee) + { + setProposedTransaction(proposedTransaction); + setExpiration(expiration); + } + + /** + * @brief Construct a TransactionProposalCreateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + TransactionProposalCreateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttTRANSACTION_PROPOSAL_CREATE) + { + throw std::runtime_error("Invalid transaction type for TransactionProposalCreateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfProposedTransaction (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalCreateBuilder& + setProposedTransaction(STObject const& value) + { + object_.setFieldObject(sfProposedTransaction, value); + return *this; + } + + /** + * @brief Set sfExpiration (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalCreateBuilder& + setExpiration(std::decay_t const& value) + { + object_[sfExpiration] = value; + return *this; + } + + /** + * @brief Build and return the TransactionProposalCreate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + TransactionProposalCreate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return TransactionProposalCreate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index aabde69ff9..1a983ce27a 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -60,6 +60,10 @@ public: , j(j) { XRPL_ASSERT((flags & TapBatch) == TapBatch, "Batch apply flag should be set"); + XRPL_ASSERT_IF( + (flags & TapProposal) != TapNone, + (flags & TapDryRun) != TapNone, + "xrpl::PreflightContext : proposal preflight implies dry run"); } PreflightContext( @@ -71,6 +75,10 @@ public: : registry(registry), tx(tx), rules(std::move(rules)), flags(flags), j(j) { XRPL_ASSERT((flags & TapBatch) == 0, "Batch apply flag should not be set"); + XRPL_ASSERT_IF( + (flags & TapProposal) != TapNone, + (flags & TapDryRun) != TapNone, + "xrpl::PreflightContext : proposal preflight implies dry run"); } PreflightContext& diff --git a/include/xrpl/tx/applySteps.h b/include/xrpl/tx/applySteps.h index 0a1ae9fa3a..f4137f29d3 100644 --- a/include/xrpl/tx/applySteps.h +++ b/include/xrpl/tx/applySteps.h @@ -386,6 +386,23 @@ preflight( PreclaimResult preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, OpenView const& view); +/** + * Type-erased overload of Transactor::invokeCheckPermission. + * + * Dispatches on the transaction type to Transactor::invokeCheckPermission + * so a caller that only has an STTx still gets the same verdict submission + * uses: transaction-level permission, then granular permissions and + * checkGranularSandbox, then that type's checkGranularSemantics. Does not + * check SignerList or signing keys. + * + * An unknown transaction type (should not occur after a successful preflight) + * is treated as an internal invariant violation: UNREACHABLE is fired and the + * type-erased fallback return is temUNKNOWN, mirroring the sibling + * invokePreflight/invokePreclaim/invokeApply overloads in this header. + */ +NotTEC +invokeCheckPermission(ReadView const& view, STTx const& tx); + /** * Compute only the expected base fee for a transaction. * diff --git a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h new file mode 100644 index 0000000000..a8c36b5611 --- /dev/null +++ b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class TransactionProposalCreate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit TransactionProposalCreate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/ProposalHelpers.cpp b/src/libxrpl/ledger/helpers/ProposalHelpers.cpp new file mode 100644 index 0000000000..4adf57ab3a --- /dev/null +++ b/src/libxrpl/ledger/helpers/ProposalHelpers.cpp @@ -0,0 +1,39 @@ +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::proposal { + +bool +isValidProposal(STObject const& proposedTx) +{ + if (isProposalTx(proposedTx)) + return false; + + if (isPseudoTx(proposedTx)) + return false; + + if (proposedTx.isFieldPresent(sfFlags) && + (proposedTx.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u) + return false; + + if (proposedTx.getFieldU16(sfTransactionType) == ttBATCH && + proposedTx.isFieldPresent(sfRawTransactions)) + { + STArray const& innerTxns = proposedTx.getFieldArray(sfRawTransactions); + for (STObject const& inner : innerTxns) + { + if (isProposalTx(inner) || isPseudoTx(inner)) + return false; + } + } + + return true; +} + +} // namespace xrpl::proposal diff --git a/src/libxrpl/ledger/helpers/SponsorHelpers.cpp b/src/libxrpl/ledger/helpers/SponsorHelpers.cpp index 7e0c041854..433a5f74b5 100644 --- a/src/libxrpl/ledger/helpers/SponsorHelpers.cpp +++ b/src/libxrpl/ledger/helpers/SponsorHelpers.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,7 @@ isReserveSponsorAllowed(TxType txType) ttACCOUNT_SET, ttREGULAR_KEY_SET, ttSPONSORSHIP_TRANSFER, + ttTRANSACTION_PROPOSAL_CREATE, }; return kReserveSponsorAllowed.contains(txType); } @@ -255,6 +257,8 @@ isLedgerEntryOwner(ReadView const& view, SLE const& sle, AccountID const& accoun // to tecNO_PERMISSION. return false; } + case ltTRANSACTION_PROPOSAL: + return sle.getAccountID(sfOwner) == account; default: // LCOV_EXCL_START UNREACHABLE("xrpl::isLedgerEntryOwner : object is not supported by sponsorship."); @@ -278,6 +282,7 @@ isLedgerEntrySupportedBySponsorship(SLE const& sle) case ltSIGNER_LIST: case ltCREDENTIAL: case ltRIPPLE_STATE: + case ltTRANSACTION_PROPOSAL: return true; default: return false; @@ -304,6 +309,11 @@ getLedgerEntryOwnerCount(SLE const& sle) return 1; return 2 + static_cast(sle.getFieldArray(sfSignerEntries).size()); } + case ltTRANSACTION_PROPOSAL: + // Mirror TransactionProposalCreate's own reserve sizing so that + // creation and sponsorship accounting agree: a proposed Batch + // reserves more than an ordinary proposal. + return proposal::proposalOwnerCount(sle.getFieldObject(sfProposedTransaction)); case ltACCOUNT_ROOT: // LCOV_EXCL_START UNREACHABLE("AccountRoots are not supported by object sponsorship."); diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 91ed5c893f..aa1d3b0bb5 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -104,6 +104,7 @@ enum class LedgerNameSpace : std::uint16_t { LoanBroker = 'l', // lower-case L Loan = 'L', Sponsorship = '>', + TransactionProposal = 'y', // No longer used or supported. Left here to reserve the space to avoid accidental reuse. Contract [[deprecated]] = 'c', @@ -361,6 +362,14 @@ check(AccountID const& id, SeqProxy const& seq) noexcept return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq.value())}; } +Keylet +txProposal(AccountID const& target, std::uint32_t ticketSequence) noexcept +{ + return { + ltTRANSACTION_PROPOSAL, + indexHash(LedgerNameSpace::TransactionProposal, target, ticketSequence)}; +} + Keylet depositPreauth(AccountID const& owner, AccountID const& preauthorized) noexcept { diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp index 00ac9f9983..9a1636eff1 100644 --- a/src/libxrpl/tx/applySteps.cpp +++ b/src/libxrpl/tx/applySteps.cpp @@ -341,6 +341,19 @@ preflight( beast::Journal j) { PreflightContext const pfCtx(registry, tx, rules, flags, j); + + // XRPL_ASSERT_IF in the PreflightContext constructor only fires in debug + // builds; re-check the same invariant here so a release build can't + // silently skip a proposed transaction's signature-presence checks + // outside of a dry run. + if ((flags & TapProposal) != TapNone && (flags & TapDryRun) == TapNone) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "apply (preflight): TapProposal set without TapDryRun."; + return {pfCtx, {tefEXCEPTION, TxConsequences{tx}}}; + // LCOV_EXCL_STOP + } + try { return {pfCtx, invokePreflight(pfCtx)}; @@ -362,6 +375,16 @@ preflight( beast::Journal j) { PreflightContext const pfCtx(registry, tx, parentBatchId, rules, flags, j); + + // See the comment in the other preflight() overload above. + if ((flags & TapProposal) != TapNone && (flags & TapDryRun) == TapNone) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "apply (preflight): TapProposal set without TapDryRun."; + return {pfCtx, {tefEXCEPTION, TxConsequences{tx}}}; + // LCOV_EXCL_STOP + } + try { return {pfCtx, invokePreflight(pfCtx)}; @@ -373,6 +396,27 @@ preflight( } } +NotTEC +invokeCheckPermission(ReadView const& view, STTx const& tx) +{ + try + { + return withTxnType(view.rules(), tx.getTxnType(), [&]() { + return Transactor::invokeCheckPermission(view, tx); + }); + } + // LCOV_EXCL_START + catch (UnknownTxnType const& e) + { + // Should never happen + JLOG(debugLog().fatal()) << "Unknown transaction type in invokeCheckPermission: " + << e.txnType; + UNREACHABLE("xrpl::invokeCheckPermission : unknown transaction type"); + return temUNKNOWN; + } + // LCOV_EXCL_STOP +} + PreclaimResult preclaim(PreflightResult const& preflightResult, ServiceRegistry& registry, OpenView const& view) { diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index da2eb609d8..85de6332d9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -87,7 +88,10 @@ LoanSet::preflight(PreflightContext const& ctx) return tx.getFieldObject(sfCounterpartySignature); return std::nullopt; }(); - if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig) + // A proposed LoanSet is stored unsigned; its CounterpartySignature is + // collected on-ledger afterward, so its absence here is expected, not an + // error (On-Chain Cosigner spec §5.3.1.2). + if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && (ctx.flags & TapProposal) == 0) { JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature."; return temBAD_SIGNER; diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp new file mode 100644 index 0000000000..3ac7ead168 --- /dev/null +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -0,0 +1,365 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +TransactionProposalCreate::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfExpiration] == 0) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: zero expiration."; + return temBAD_EXPIRATION; + } + + STObject const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); + + // The proposed transaction must pass "the same [stateless format] + // checks it would receive if submitted directly" (On-Chain Cosigner + // spec §5.3.1 rule 2), so no statically-dead proposal can be stored. + // That is exactly the pair checkValidity runs on a direct submission: + // xrpl::preflight (the transactor's own preflight chain) and + // passesLocalChecks. TapDryRun accepts the unsigned canonical form; + // TapProposal skips signature-presence checks (§5.3.1.2). A failure + // surfaces the proposed type's own code, or temMALFORMED if the + // payload is not even a valid instance of that type. + try + { + STTx const stx{STObject{proposedTx}}; + auto const inner = + xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun | TapProposal, ctx.j); + if (!isTesSuccess(inner.ter)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "failed preflight: " + << transHuman(inner.ter); + return inner.ter; + } + if (std::string reason; !passesLocalChecks(stx, reason)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "fails local checks: " + << reason; + return temMALFORMED; + } + } + catch (std::exception const& e) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is " + "malformed: " + << e.what(); + return temMALFORMED; + } + + // The proposed transaction must be independently submittable through the + // ordinary multi-sign path: no nested proposals, no pseudo-transactions, + // no batch inner transactions — and, if it is a Batch, none of its own + // inner transactions may be a nested proposal or a pseudo-transaction + // either. + if (!proposal::isValidProposal(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is not " + "independently submittable."; + return temINVALID; + } + + // The proposed transaction is stored in its unsigned canonical form; the + // ledger populates its signature fields as contributions arrive. + if (proposal::hasSignatureField(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "carries signature fields."; + return temBAD_SIGNER; + } + + if (!proposal::hasEmptySigningPubKey(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "SigningPubKey must be present and empty."; + return temBAD_SIGNER; + } + + // The proposed transaction must be ticket-based: it must carry a + // TicketSequence and must not use a live Sequence. Sequence is a required + // common field, so "no Sequence" is expressed as a Sequence of 0 rather + // than an absent field. A ticket decouples the proposal from the target + // account's live sequence, so unrelated target-account activity cannot + // invalidate it while signatures are collected (On-Chain Cosigner spec + // §4.2.1). + if (!proposedTx.isFieldPresent(sfTicketSequence) || proposedTx.getFieldU32(sfSequence) != 0) + return temSEQ_AND_TICKET; + + // If this transaction itself is paying with a Ticket, and the proposed + // transaction is targeting that same account and Ticket, then applying + // this transaction consumes the very Ticket the proposal depends on + // before the proposal is even stored: the proposal would be dead on + // arrival, and its only recourse would be TransactionProposalCancel. + if (ctx.tx.getSeqProxy().isTicket() && + proposedTx.getAccountID(sfAccount) == ctx.tx.getAccountID(sfAccount) && + proposedTx.getFieldU32(sfTicketSequence) == ctx.tx.getSeqProxy().value()) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "reuses the Ticket this transaction itself consumes."; + return temMALFORMED; + } + + return tesSUCCESS; +} + +TER +TransactionProposalCreate::preclaim(PreclaimContext const& ctx) +{ + if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: already expired."; + return tecEXPIRED; + } + + auto const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); + + // Once the proposed transaction's own ledger bound has passed it can never + // be applied, so the proposal is dead on arrival. The bound is the one the + // ordinary path uses for tefMAX_LEDGER: the last ledger in which the + // proposed transaction may still be submitted (On-Chain Cosigner spec + // §4.5). + if (proposedTx.isFieldPresent(sfLastLedgerSequence) && + proposedTx.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq()) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "LastLedgerSequence has passed."; + return tecEXPIRED; + } + + AccountID const target = proposedTx.getAccountID(sfAccount); + auto const sleTarget = ctx.view.read(keylet::account(target)); + if (!sleTarget) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: target account " + "does not exist."; + return tecNO_TARGET; + } + + // A pseudo-account cannot authorize a transaction through a SignerList. + if (isPseudoAccount(sleTarget)) + return tecNO_PERMISSION; + + // Only the target account itself, an account on its SignerList, or (if + // the proposed transaction's own type has been delegated by the target, + // Permission Delegation / XLS-75) that delegate or an account on the + // delegate's own SignerList, may create a proposal against it. Otherwise + // any account could spam or squat the target's Tickets with unwanted + // proposals (On-Chain Cosigner V1 scope). + if (AccountID const proposer = ctx.tx.getAccountID(sfAccount); proposer != target) + { + // Whether `proposer` is `account` itself or an entry on `account`'s + // applicable SignerList. + auto isAuthorizedFor = [&](AccountID const& account) -> std::expected { + if (proposer == account) + return true; + + auto const sleSigners = ctx.view.read(keylet::signerList(account)); + if (!sleSigners) + return false; + + // deserialize itself returns unexpected(temMALFORMED) when + // sfSignerEntries is missing or an element is not an sfSignerEntry. + // Those are the right codes for a transaction object. Here the object + // is an on-ledger ltSIGNER_LIST (sfSignerEntries is SoeRequired; + // each element is an sfSignerEntry). A corrupt SLE can still throw + // from the STObject accessors deserialize calls: getFieldArray + // ("Wrong field type") or getAccountID/getFieldU16 ("Field not + // found") when an sfSignerEntry is missing required fields. Either + // the expected<> error or a throw is unexpected ledger state, not a + // malformed TransactionProposalCreate, so tefBAD_LEDGER (rather + // than tefINTERNAL, which is reserved for truly unreachable code + // paths) is the right code. + try + { + auto const accountSigners = + SignerEntries::deserialize(*sleSigners, ctx.j, "ledger"); + if (!accountSigners) + { + // Only reachable if the on-ledger SignerList is corrupt + // (SignerListSet re-runs the same deserialize on write). + // Exercised by testCorruptSignerList via an OpenLedger + // overlay that produces the same failure modes. + JLOG(ctx.j.fatal()) << "TransactionProposalCreate: unparseable SignerList: " + << transToken(accountSigners.error()); + return std::unexpected(tefBAD_LEDGER); + } + + return std::ranges::any_of( + *accountSigners, [&](auto const& entry) { return entry.account == proposer; }); + } + catch (std::exception const& e) + { + // Same as above: only reachable via ledger corruption that + // makes an STObject accessor throw. Exercised by + // testCorruptSignerList. + JLOG(ctx.j.fatal()) + << "TransactionProposalCreate: unparseable SignerList: " << e.what(); + return std::unexpected(tefBAD_LEDGER); + } + }; + + auto isSigner = isAuthorizedFor(target); + if (!isSigner) + return isSigner.error(); + + // A delegate that the target has granted permission over the + // proposed transaction — or one of that delegate's own signers — is + // equally authorized: it will need to help complete the proposed + // transaction's own authorization anyway once the proposal is + // submitted. xrpl::invokeCheckPermission is the type-erased + // submission hierarchy (not checkTxPermission alone, which would + // reject a matching granular grant). Qualify xrpl:: so the inherited + // Transactor template is not chosen; it cannot deduce T here. A + // failed grant is still "not authorized" and becomes + // tecNO_PERMISSION below — Create is already signed, so do not leak + // the pre-sign terNO_DELEGATE_PERMISSION. + if (!*isSigner && proposedTx.isFieldPresent(sfDelegate)) + { + AccountID const delegateAccount = proposedTx.getAccountID(sfDelegate); + STTx const proposedStTx{STObject{proposedTx}}; + if (isTesSuccess(xrpl::invokeCheckPermission(ctx.view, proposedStTx))) + { + // A grant cannot exist without a funded authorize (DelegateSet + // uses tecNO_TARGET; AccountDelete of the delegatee removes the + // Delegate SLE). Do not treat a missing account as a Create-time + // user error — that would extra-validate the proposed tx. If + // permission passed anyway, the ledger is corrupt. + if (!ctx.view.exists(keylet::account(delegateAccount))) + return tefINTERNAL; // LCOV_EXCL_LINE + isSigner = isAuthorizedFor(delegateAccount); + if (!isSigner) + return isSigner.error(); + } + } + + if (!*isSigner) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposer is " + "not the target account, one of its " + "signers, or an authorized delegate."; + return tecNO_PERMISSION; + } + } + + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); + + // The proposal reserves the ticket for as long as it exists (On-Chain + // Cosigner spec §4.2.1, §5.3.2): a ticket that doesn't exist yet can't be + // reserved. + if (!ctx.view.exists(keylet::ticket(target, SeqProxy::rawTicket(ticketSequence)))) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: target ticket " + "does not exist."; + return tefNO_TICKET; + } + + if (ctx.view.exists(keylet::txProposal(target, ticketSequence))) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: duplicate proposal."; + return tecDUPLICATE; + } + + return tesSUCCESS; +} + +TER +TransactionProposalCreate::doApply() +{ + auto const sle = view().peek(keylet::account(accountID_)); + if (!sle) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const proposedTx = ctx_.tx.getFieldObject(sfProposedTransaction); + std::uint32_t const ownerCount = proposal::proposalOwnerCount(proposedTx); + + // The proposal holds a full transaction plus its collected signatures, so + // it reserves more than a typical ledger entry (5 increments; 10 for a + // proposed Batch). + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), + sle, + preFeeBalance_, + {.ownerCountDelta = static_cast(ownerCount)}, + ctx_.journal); + !isTesSuccess(ret)) + return ret; + + AccountID const target = proposedTx.getAccountID(sfAccount); + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); + + Keylet const proposalKeylet = keylet::txProposal(target, ticketSequence); + auto sleProposal = std::make_shared(proposalKeylet); + sleProposal->setAccountID(sfOwner, accountID_); + sleProposal->setFieldObject(sfProposedTransaction, proposedTx); + sleProposal->setFieldU32(sfExpiration, ctx_.tx[sfExpiration]); + + view().insert(sleProposal); + + auto viewJ = ctx_.registry.get().getJournal("View"); + { + auto const page = view().dirInsert( + keylet::ownerDir(accountID_), proposalKeylet, describeOwnerDir(accountID_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + sleProposal->setFieldU64(sfOwnerNode, *page); + } + + increaseOwnerCount(ctx_.getApplyViewContext(), sle, ownerCount, viewJ); + addSponsorToLedgerEntry(ctx_.getApplyViewContext(), sleProposal); + return tesSUCCESS; +} + +void +TransactionProposalCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). Object-level + // invariants for the TransactionProposal ledger entry (unsigned canonical + // form, non-zero Expiration, correct ProposalID key, sorted/unique signer + // arrays) belong in a protocol-level ValidTransactionProposal check. +} + +bool +TransactionProposalCreate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp index 642a415be7..46c6ad4800 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp @@ -201,7 +201,11 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx) bool const isAccountReserveSponsorship = isCreateOrReassign && reserveSponsor && !ctx.tx.isFieldPresent(sfObjectID); - if (isAccountReserveSponsorship && !ctx.tx.isFieldPresent(sfSponsorSignature)) + // A proposed SponsorshipTransfer is stored unsigned; its SponsorSignature + // is collected on-ledger afterward, so its absence here is expected, not + // an error (On-Chain Cosigner spec §5.3.1.2). + if (isAccountReserveSponsorship && !ctx.tx.isFieldPresent(sfSponsorSignature) && + (ctx.flags & TapProposal) == 0) { JLOG(ctx.j.debug()) << "preflight: account sponsorship requires sfSponsorSignature"; return temMALFORMED; diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index dcd06453aa..e14611917e 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -348,8 +348,20 @@ Batch::preflight(PreflightContext const& ctx) return temINVALID_FLAG; auto const innerAccount = stx.getAccountID(sfAccount); + // TransactionProposalCreate preflights a proposed Batch with + // TapDryRun | TapProposal so signature-presence checks are deferred + // to collection time (On-Chain Cosigner spec §5.3.1.2). Inner + // preflight used to pass only TapBatch, so those bits never reached + // the inners: an unsigned account-reserve SponsorshipTransfer then + // demanded sfSponsorSignature and the Create failed with + // temINVALID_INNER_BATCH. Spec §6.1.1 names an inner Sponsor as a + // collectable slot, so forward TapProposal/TapDryRun. Always OR in + // TapBatch — PreflightContext with a parentBatchId requires it. + // LoanSet already short-circuits on tfInnerBatchTxn; it is also in + // kDisabledTxTypes, so it never reaches this call. + ApplyFlags const innerFlags = TapBatch | (ctx.flags & (TapProposal | TapDryRun)); if (auto const preflightResult = - xrpl::preflight(ctx.registry, ctx.rules, parentBatchId, stx, TapBatch, ctx.j); + xrpl::preflight(ctx.registry, ctx.rules, parentBatchId, stx, innerFlags, ctx.j); !isTesSuccess(preflightResult.ter)) { JLOG(ctx.j.debug()) << "BatchTrace[" << parentBatchId << "]: " @@ -415,6 +427,13 @@ Batch::preflightSigValidated(PreflightContext const& ctx) { XRPL_ASSERT( ctx.tx.getTxnType() == ttBATCH, "xrpl::Batch::preflightSigValidated : batch transaction"); + + // A proposed Batch is stored unsigned; its BatchSigners are collected + // on-ledger afterward, so the signer-presence match belongs to submission + // time, not proposal creation (On-Chain Cosigner spec §5.3.1.2). + if ((ctx.flags & TapProposal) != 0) + return tesSUCCESS; + auto const parentBatchId = ctx.tx.getTransactionID(); auto const outerAccount = ctx.tx.getAccountID(sfAccount); // Accounts that must sign the batch: each inner authorizer and counterparty diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp new file mode 100644 index 0000000000..96e0e77ba8 --- /dev/null +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -0,0 +1,1665 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include // IWYU pragma: keep +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +struct TransactionProposalCreate_test : public beast::unit_test::Suite +{ + void + testReserveCounts() + { + testcase("proposal reserve"); + + using namespace jtx; + + BEAST_EXPECT(proposal::kProposalOwnerCount == 5); + BEAST_EXPECT(proposal::kBatchProposalOwnerCount == 10); + } + + // Nothing about the transaction is available before the amendment is + // active, not even to an otherwise valid proposal. + void + testDisabled(FeatureBitset features) + { + testcase("amendment disabled"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features - featureCosign}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + env(proposal::create( + target, + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq), + proposal::expiration(env, 100s)), + Ter(temDISABLED), + proposal::verify::create()); + env.close(); + + // Its own Ticket is the only thing target owns; the rejected + // proposal adds nothing on top of it. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // The proposed transaction must be a transaction that could be submitted on + // its own. Each case below takes an otherwise valid payload and breaks + // exactly one of those rules; the rules about its signature fields are + // covered by testRejectedSignatureFields. + void + testRejectedPayload(FeatureBitset features) + { + testcase("reject payload that must not be stored"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + + // A payload that is accepted as-is; every case starts from this. + auto payload = [&]() { + return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + }; + + // target's own Ticket is the only thing it owns throughout; a + // rejected proposal never adds anything on top of it. + auto reject = [&](json::Value const& proposedTx, TER expected) { + env(proposal::create(target, proposedTx, expiration), + Ter(expected), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 1); + }; + + // An unrecognized TransactionType cannot even be constructed as an + // STTx (there is no format to validate it against), so it is + // rejected the same as any other malformed payload. + { + json::Value tx = payload(); + tx[jss::TransactionType] = 65535; + reject(tx, temMALFORMED); + } + + // A pseudo-transaction is never submittable by an account. This + // payload also carries Payment-shaped fields (Amount, Destination) + // that aren't part of EnableAmendment's own template, so it fails + // STTx construction before ever reaching our own isPseudoTx check. + { + json::Value tx = payload(); + tx[jss::TransactionType] = jss::EnableAmendment; + reject(tx, temMALFORMED); + } + + // An inner batch transaction bypasses the ordinary signature checks. + // The proposed transaction's own preflight rejects a standalone + // tfInnerBatchTxn (no enclosing Batch, no parentBatchId) with its own + // more specific code before reaching our own tfInnerBatchTxn check. + { + json::Value tx = payload(); + tx[jss::Flags] = tfInnerBatchTxn; + reject(tx, temINVALID_INNER_BATCH); + } + + // Proposals do not nest. This payload also isn't a valid instance of + // TransactionProposalCreate's own template (it lacks Expiration and + // ProposedTransaction), so it fails STTx construction before ever + // reaching our own isProposalTx check. + { + json::Value tx = payload(); + tx[jss::TransactionType] = "TransactionProposalCreate"; + reject(tx, temMALFORMED); + } + + // Nor may a proposed Batch smuggle a nested proposal in as one of its + // own inner transactions. A second, ordinary inner transaction rides + // along only to satisfy Batch's own minimum of two inner + // transactions; it isn't itself the point of this case. + { + json::Value const nestedProposal = + proposal::create(target, payload(), proposal::expiration(env, 100s)); + json::Value const tx = proposal::unsignedBatch( + env, + target, + targetTicketSeq, + tfAllOrNothing, + {proposal::innerTx(nestedProposal, env.seq(target)), + proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)}); + reject(tx, temINVALID); + } + + // The proposed transaction must be ticket-based: a missing + // TicketSequence, or a live Sequence alongside it, is rejected. + { + json::Value tx = payload(); + tx.removeMember(sfTicketSequence.getJsonName()); + reject(tx, temSEQ_AND_TICKET); + } + { + json::Value tx = payload(); + tx[jss::Sequence] = 1; + reject(tx, temSEQ_AND_TICKET); + } + + // If this TransactionProposalCreate itself pays with a Ticket, and the + // proposed transaction targets that same account and Ticket, applying + // this transaction consumes the Ticket the proposal depends on before + // the proposal is even stored: it would be dead on arrival. target is + // proposing for itself here, so this is the Ticket it is about to pay + // with and the Ticket its own proposed payload names. + { + std::uint32_t const selfTicketSeq = proposal::createTicket(env, target); + + json::Value const tx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), selfTicketSeq); + env(proposal::create(target, tx, expiration), + ticket::Use(selfTicketSeq), + Ter(temMALFORMED)); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, selfTicketSeq)); + BEAST_EXPECT(env.le(keylet::ticket(target.id(), SeqProxy::rawTicket(selfTicketSeq)))); + BEAST_EXPECT(ownerCount(env, target) == 2); + + // Consume the leftover Ticket so target's OwnerCount is back to + // just its original Ticket for the remaining cases below. + env(noop(target), ticket::Use(selfTicketSeq)); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A payload that fails its own transaction type's preflight surfaces + // that type's own code, not a generic error (On-Chain Cosigner spec §5.3.1.2). + { + json::Value tx = payload(); + tx[jss::Amount] = "0"; + reject(tx, temBAD_AMOUNT); + } + + // A payload that fails passesLocalChecks (On-Chain Cosigner spec + // §5.3.1 rule 2) is rejected as temMALFORMED. An oversized Memos + // array trips isMemoOkay; no transactor preflight step bounds + // memo size. + { + json::Value tx = payload(); + tx[sfMemos.jsonName][0u][sfMemo.jsonName][sfMemoData.jsonName] = + strHex(std::string(1100, 'A')); // > 1024 bytes serialized + reject(tx, temMALFORMED); + } + + // Expiration must be present and non-zero. + { + env(proposal::create(target, payload(), 0), + Ter(temBAD_EXPIRATION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 1); + } + } + + // A proposal is stored in unsigned canonical form: an empty SigningPubKey + // and no signature field whatsoever. Signatures may only ever arrive + // through TransactionProposalSign, so a payload is rejected for carrying a + // signature container at all — whatever that container happens to hold. + // Each container below is therefore filled every way it could be, + // including combinations that could never verify: an empty container, a + // key with no signature, a signature with no key, and a signature next to + // the empty SigningPubKey the canonical form requires. + // + // The rejection code is not uniform, though. A fill that leaves actual + // signature bytes behind (a non-empty TxnSignature, or one inside a + // Signers entry) is, for some containers, intercepted before ever + // reaching our own hasSignatureField check: the payload's own top-level + // fields are checked by Transactor::preflight2's dry-run simulate-key + // logic, and LoanSet forwards its CounterpartySignature through that same + // logic, both yielding temINVALID instead. SponsorSignature and + // BatchSigners are not inspected that way — only their presence is + // checked elsewhere — so they always reach our own check regardless of + // what they hold. + void + testRejectedSignatureFields(FeatureBitset features) + { + testcase("reject payload carrying a signature"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + + std::string const key = strHex(bob.pk().slice()); + std::string const sig = "DEADBEEF"; + + // The payloads every case starts from, each accepted as-is. Two cases + // below would otherwise build the same payload, and the same proposal + // cannot be submitted twice — the second is turned away as a duplicate + // rather than judged again — so each call pays a different amount. + // Nothing here turns on the amount. + std::uint32_t paid = 0; + auto payment = [&]() { + return proposal::unsignedPayload(env, pay(target, bob, drops(++paid)), targetTicketSeq); + }; + auto sponsoredPayment = [&]() { + // bob is just standing in for an arbitrary sponsor here; every case + // is rejected for carrying a signature field before the sponsor + // itself is ever examined. + json::Value tx = pay(target, bob, drops(++paid)); + tx[sfSponsor.getJsonName()] = bob.human(); + tx[sfSponsorFlags.getJsonName()] = spfSponsorFee; + return proposal::unsignedPayload(env, tx, targetTicketSeq); + }; + auto loanSet = [&]() { + json::Value tx = loan::set(target, uint256{1}, 1'000 + ++paid); + tx[sfCounterparty.getJsonName()] = bob.human(); + return proposal::unsignedPayload(env, tx, targetTicketSeq); + }; + auto batchTx = [&]() { + return proposal::unsignedBatch( + env, + target, + targetTicketSeq, + tfAllOrNothing, + {proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target)), + proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target) + 1)}); + }; + + // target's own Ticket is the only thing it owns throughout; a + // rejected proposal never adds anything on top of it. + auto reject = [&](json::Value const& proposedTx, TER expected) { + env(proposal::create(target, proposedTx, expiration), + Ter(expected), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 1); + }; + + // Every way of filling in a signature. The payload's own signature + // fields and a co-signature object hold the same three members, so the + // same fills apply to both. `signs` marks a fill that leaves actual + // signature bytes behind, which some containers' own dry-run + // simulate-key check reacts to (see the class comment above). + struct Fill + { + std::function apply; + bool signs; + }; + + std::vector const fills{ + {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = key; }, .signs = false}, + {.apply = [&](json::Value& o) { o[sfTxnSignature.getJsonName()] = sig; }, + .signs = true}, + {.apply = + [&](json::Value& o) { + o[jss::SigningPubKey] = ""; + o[sfTxnSignature.getJsonName()] = sig; + }, + .signs = true}, + // Signed the ordinary way, which is the likeliest way one of these + // arrives here. + {.apply = + [&](json::Value& o) { + o[jss::SigningPubKey] = key; + o[sfTxnSignature.getJsonName()] = sig; + }, + .signs = true}, + // Multi-signed: the signer's own key is empty and the signatures + // sit in a nested Signers array. Each entry needs all three of + // Account, SigningPubKey and TxnSignature to parse at all, so only + // their values can vary. + {.apply = + [&](json::Value& o) { + o[jss::SigningPubKey] = ""; + auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; + signer[jss::Account] = bob.human(); + signer[jss::SigningPubKey] = key; + signer[sfTxnSignature.getJsonName()] = sig; + }, + .signs = true}, + {.apply = + [&](json::Value& o) { + o[jss::SigningPubKey] = ""; + auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; + signer[jss::Account] = bob.human(); + signer[jss::SigningPubKey] = ""; + signer[sfTxnSignature.getJsonName()] = sig; + }, + .signs = true}, + }; + + // Every place a signature could sit, on a payload of a type that + // carries it: a Counterparty's signature belongs to a LoanSet and + // BatchSigners to a Batch, while a Sponsor's signature and the + // payload's own signature fields sit on any transaction. A signature + // is no more storable for being a field its transaction type expects + // (On-Chain Cosigner spec §6.1, §6.6.3). `checksSignatureContent` + // marks a place whose own preflight forwards the container through a + // dry-run simulate-key check, the same as the payload's own top-level + // fields. + struct Place + { + std::function payload; + std::function at; + bool checksSignatureContent; + }; + + std::vector const places{ + {.payload = payment, + .at = [](json::Value& tx) -> json::Value& { return tx; }, + .checksSignatureContent = true}, + {.payload = loanSet, + .at = [](json::Value& tx) -> json::Value& { + auto& o = tx[sfCounterpartySignature.getJsonName()]; + o = json::Value{json::ValueType::Object}; + return o; + }, + .checksSignatureContent = true}, + {.payload = sponsoredPayment, + .at = [](json::Value& tx) -> json::Value& { + auto& o = tx[sfSponsorSignature.getJsonName()]; + o = json::Value{json::ValueType::Object}; + return o; + }, + .checksSignatureContent = false}, + // A BatchSigners entry names the account it speaks for; the other + // two co-signatures are fixed by the transaction they belong to and + // do not. + {.payload = batchTx, + .at = [&](json::Value& tx) -> json::Value& { + auto& o = tx[sfBatchSigners.getJsonName()][0u][sfBatchSigner.getJsonName()]; + o[jss::Account] = bob.human(); + return o; + }, + .checksSignatureContent = false}, + }; + + for (auto const& place : places) + { + for (auto const& fill : fills) + { + json::Value tx = place.payload(); + fill.apply(place.at(tx)); + reject(tx, place.checksSignatureContent && fill.signs ? temINVALID : temBAD_SIGNER); + } + } + + // Every place but the payload itself: a co-signature object is + // disqualifying by its presence alone, so each is rejected left empty + // too. The payload's own fields have no such case — left alone they are + // the canonical form. An empty container never trips a simulate-key + // check, so this is temBAD_SIGNER regardless of checksSignatureContent. + for (std::size_t i = 1; i < places.size(); ++i) + { + json::Value tx = places[i].payload(); + places[i].at(tx); + reject(tx, temBAD_SIGNER); + } + + // Nor does the payload have a counterpart for an absent SigningPubKey: + // in a co-signature object an absent member is just an unfilled one, + // but at the top level it is not the same as an empty one, with or + // without a signature beside it. SigningPubKey is a required common + // field, so its absence means proposedTx isn't a valid instance of its + // own type; that's caught while constructing it as an STTx, before + // reaching our own hasEmptySigningPubKey check. + { + json::Value tx = payment(); + tx.removeMember(jss::SigningPubKey); + reject(tx, temMALFORMED); + } + { + json::Value tx = payment(); + tx.removeMember(jss::SigningPubKey); + tx[sfTxnSignature.getJsonName()] = sig; + reject(tx, temMALFORMED); + } + } + + // A proposal that could never be completed must not be stored, and a + // target-and-ticket pair may hold at most one proposal. + void + testPreclaim(FeatureBitset features) + { + testcase("reject proposal that cannot be completed"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + Account const carol{"carol"}; // never funded + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const firstTicketSeq = proposal::createTicket(env, target, 3); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + + auto payload = [&](std::uint32_t ticketSeq) { + return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq); + }; + + // target's three Tickets are owned throughout, so its OwnerCount + // never drops below 3; each successful proposal adds kProposalOwnerCount + // on top of that baseline. + + // The proposal's own expiration has already passed. + { + env(proposal::create(target, payload(firstTicketSeq), proposal::expiration(env, 0s)), + Ter(tecEXPIRED), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3); + } + + // The proposed transaction's own ledger bound has passed: the ordinary + // path would reject it with tefMAX_LEDGER, so it can never complete. + { + json::Value tx = payload(firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1; + env(proposal::create(target, tx, expiration), + Ter(tecEXPIRED), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3); + } + + // A LastLedgerSequence equal to the current ledger leaves no window to + // collect signatures before the proposed transaction's own bound + // passes, so it is rejected the same as one already in the past + // (On-Chain Cosigner spec §5.3.2.2). + { + json::Value tx = payload(firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); + env(proposal::create(target, tx, expiration), + Ter(tecEXPIRED), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3); + } + + // With no ledger bound on the proposed transaction, the proposal is + // created normally. + { + env(proposal::create(target, payload(firstTicketSeq), expiration), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount); + } + + // The target and ticket already carry a proposal. + { + env(proposal::create(target, payload(firstTicketSeq), expiration), + Ter(tecDUPLICATE), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount); + } + + // A different ticket of the same target is a different proposal. + { + env(proposal::create(target, payload(firstTicketSeq + 1), expiration), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); + } + + // The target account does not exist, so it can never sign. target + // itself is just standing in here as an arbitrary funded submitter — + // the account under test is carol, the (nonexistent) target. + { + env(proposal::create( + target, proposal::unsignedPayload(env, pay(carol, bob, XRP(1)), 1), expiration), + Ter(tecNO_TARGET), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); + } + + // The referenced ticket does not exist: the proposal would reserve a + // ticket that was never created (On-Chain Cosigner spec §5.3.2). + { + std::uint32_t const noSuchTicketSeq = firstTicketSeq + 100; + env(proposal::create(target, payload(noSuchTicketSeq), expiration), + Ter(tefNO_TICKET), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); + } + } + + // Only the target account itself, or an account on its SignerList, may + // create a proposal against it. Otherwise any unrelated account could + // spam or squat the target's Tickets with unwanted proposals (On-Chain + // Cosigner V1 authorization scope). + void + testProposerAuthorization(FeatureBitset features) + { + testcase("reject proposal from an unauthorized proposer"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const signer{"signer"}; + Account const stranger{"stranger"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, signer, stranger, bob); + env.close(); + + env(signers(target, 1, {{signer, 1}})); + env.close(); + + auto payload = [&](std::uint32_t ticketSeq) { + return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq); + }; + + // The target account itself needs no SignerList entry. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(target, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // An account on the target's SignerList may propose for it. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // An account that is neither the target nor on its SignerList may not. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + BEAST_EXPECT(ownerCount(env, stranger) == 0); + } + + // A target with no SignerList at all may only be proposed for by + // itself. + { + Account const bare{"bare"}; + env.fund(XRP(10000), bare); + env.close(); + + std::uint32_t const ticketSeq = proposal::createTicket(env, bare); + env(proposal::create( + stranger, + proposal::unsignedPayload(env, pay(bare, bob, XRP(1)), ticketSeq), + proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, bare, ticketSeq)); + } + + // A SignerList with several entries authorizes every one of them, not + // just the first, matching a real-world multi-signer setup rather + // than only ever exercising a single-signer list. + { + Account const s1{"s1"}; + Account const s2{"s2"}; + Account const s3{"s3"}; + Account const s4{"s4"}; + Account const s5{"s5"}; + env.fund(XRP(10000), s1, s2, s3, s4, s5); + env.close(); + + env(signers(target, 3, {{s1, 1}, {s2, 1}, {s3, 1}, {s4, 1}, {s5, 1}})); + env.close(); + + for (Account const& s : {s1, s2, s3, s4, s5}) + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(s, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // The old SignerList's sole signer is no longer on the new one. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + + // An unrelated account still may not. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + } + } + + // An on-ledger ltSIGNER_LIST that cannot be read as signer entries is + // unexpected ledger state, not a malformed transaction. preclaim must + // surface tefBAD_LEDGER (not temMALFORMED, not tefINTERNAL which is + // reserved for truly unreachable paths, and not the tefEXCEPTION that + // applySteps would wrap an uncaught throw with). + // + // SignerEntries::deserialize returns unexpected(temMALFORMED) when + // sfSignerEntries is missing or an element is not named sfSignerEntry. + // It still throws from STObject accessors when an sfSignerEntry is + // missing required fields (getAccountID → "Field not found: Account"). + // Do not close() after the synthetic corruption: a closed ledger would + // drop the overlay and restore a well-formed list. + void + testCorruptSignerList(FeatureBitset features) + { + testcase("unparseable on-ledger SignerList is tefBAD_LEDGER"); + + using namespace jtx; + using namespace std::chrono_literals; + + auto setup = [&](Env& env, Account const& target, Account const& signer) { + env.fund(XRP(10000), target, signer); + env.close(); + env(signers(target, 1, {{signer, 1}})); + env.close(); + // Ticket first: createTicket closes, which would drop a later + // open-ledger overlay and restore a well-formed SignerList. + return proposal::createTicket(env, target); + }; + + auto proposeAsSigner = + [&](Env& env, Account const& target, Account const& signer, std::uint32_t ticketSeq) { + env(proposal::create( + signer, + proposal::unsignedPayload(env, pay(target, signer, XRP(1)), ticketSeq), + proposal::expiration(env, 100s)), + Ter(tefBAD_LEDGER), + proposal::verify::create()); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + }; + + { + Env env{*this, features}; + Account const target{"targetMissing"}; + Account const signer{"signerMissing"}; + std::uint32_t const ticketSeq = setup(env, target, signer); + + auto const signerListKeylet = keylet::signerList(target.id()); + BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + auto const sle = view.read(signerListKeylet); + if (!sle) + return false; + auto replacement = std::make_shared(*sle); + if (!replacement->delField(sfSignerEntries)) + return false; + view.rawReplace(replacement); + return true; + })); + BEAST_EXPECT(env.le(signerListKeylet)); + + proposeAsSigner(env, target, signer, ticketSeq); + } + + { + Env env{*this, features}; + Account const target{"targetBadEntry"}; + Account const signer{"signerBadEntry"}; + std::uint32_t const ticketSeq = setup(env, target, signer); + + auto const signerListKeylet = keylet::signerList(target.id()); + BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + auto const sle = view.read(signerListKeylet); + if (!sle) + return false; + auto replacement = std::make_shared(*sle); + STArray badEntries; + badEntries.pushBack(STObject{sfSigner}); + replacement->setFieldArray(sfSignerEntries, badEntries); + view.rawReplace(replacement); + return true; + })); + BEAST_EXPECT(env.le(signerListKeylet)); + + proposeAsSigner(env, target, signer, ticketSeq); + } + + { + Env env{*this, features}; + Account const target{"targetMissingAccount"}; + Account const signer{"signerMissingAccount"}; + std::uint32_t const ticketSeq = setup(env, target, signer); + + auto const signerListKeylet = keylet::signerList(target.id()); + BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + auto const sle = view.read(signerListKeylet); + if (!sle) + return false; + auto replacement = std::make_shared(*sle); + STArray badEntries; + // Right inner name, but no sfAccount: deserialize calls + // getAccountID and throws (Field not found), which the + // catch maps to tefBAD_LEDGER. + badEntries.pushBack(STObject{sfSignerEntry}); + replacement->setFieldArray(sfSignerEntries, badEntries); + view.rawReplace(replacement); + return true; + })); + BEAST_EXPECT(env.le(signerListKeylet)); + + proposeAsSigner(env, target, signer, ticketSeq); + } + } + + // The target account may delegate authority over the proposed + // transaction's own type to another account (Permission Delegation, + // XLS-75); if it does, that delegate — or an account on the delegate's + // own SignerList — may also create the proposal, since it will need to + // help complete the proposed transaction's own authorization anyway. + // Naming an account as Delegate in the proposed transaction is not + // itself trusted: a real DelegateSet grant is required. + void + testDelegatedProposedTx(FeatureBitset features) + { + testcase("proposer authorized through a delegated proposed txn"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const delegateAcct{"delegateAcct"}; + Account const ds1{"ds1"}; // on delegateAcct's own SignerList + Account const ds2{"ds2"}; // on delegateAcct's own SignerList + Account const stranger{"stranger"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, delegateAcct, ds1, ds2, stranger, bob); + env.close(); + + auto delegatedPayload = [&](std::uint32_t ticketSeq) { + json::Value tx = pay(target, bob, XRP(1)); + tx[sfDelegate.jsonName] = delegateAcct.human(); + return proposal::unsignedPayload(env, tx, ticketSeq); + }; + + // Without a real DelegateSet grant, naming an account as Delegate in + // the proposed transaction does not authorize it. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create( + delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + + // The target grants delegateAcct permission over Payment transactions. + env(delegate::set(target, delegateAcct, {"Payment"})); + env.close(); + + // The delegate itself may now create the proposal. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create( + delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // An account on the delegate's own SignerList may likewise create it. + { + env(signers(delegateAcct, 1, {{ds1, 1}, {ds2, 1}})); + env.close(); + + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create(ds1, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // An account with no relationship to the target or the delegate is + // still rejected. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create( + stranger, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + } + + // A delegate holding only a granular permission (XLS-75) that would + // authorize submitting the proposed transaction may also create a + // proposal for it. A granular grant that fails checkGranularSandbox + // still cannot. + void + testDelegatedGranularProposedTx(FeatureBitset features) + { + testcase("proposer authorized through granular delegate permission"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const gw{"gw"}; // issuer / proposed-tx Account + Account const alice{"alice"}; // holder of the trust line being authorized + Account const bob{"bob"}; // delegate with TrustlineAuthorize only + env.fund(XRP(10000), gw, alice, bob); + env(fset(gw, asfRequireAuth)); + env.close(); + + env(trust(alice, gw["USD"](50))); + env.close(); + env(delegate::set(gw, bob, {"TrustlineAuthorize"})); + env.close(); + + auto delegatedTrustSet = [&](std::uint32_t ticketSeq, std::uint32_t flags) { + json::Value tx = trust(gw, gw["USD"](0), alice, flags); + tx[sfDelegate.jsonName] = bob.human(); + return proposal::unsignedPayload(env, tx, ticketSeq); + }; + + // TrustlineAuthorize is sufficient for a tfSetfAuth TrustSet against + // an existing line whose limit is unchanged — the same shape that + // submits successfully under invokeCheckPermission. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, gw); + env(proposal::create( + bob, delegatedTrustSet(ticketSeq, tfSetfAuth), proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, gw, ticketSeq)); + } + + // tfSetFreeze is not in TrustlineAuthorize's sandbox. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, gw); + env(proposal::create( + bob, + delegatedTrustSet(ticketSeq, tfSetFreeze), + proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq)); + } + + // sfQualityOut is a valid TrustSet field but not in the granular + // template, so checkGranularSandbox rejects it. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, gw); + json::Value tx = trust(gw, gw["USD"](0), alice, tfSetfAuth); + tx[sfDelegate.jsonName] = bob.human(); + tx[sfQualityOut.jsonName] = 100; + env(proposal::create( + bob, + proposal::unsignedPayload(env, tx, ticketSeq), + proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq)); + } + } + + // Same failure mode as testCorruptSignerList, but reached through the + // delegate branch: preclaim looks up the delegate's own SignerList when + // the proposer is neither the target, on the target's SignerList, nor the + // delegate itself. If the delegate's SignerList is unparseable, preclaim + // must surface tefBAD_LEDGER — exercising the second isAuthorizedFor call + // that runs against the delegate rather than the target. + void + testCorruptDelegateSignerList(FeatureBitset features) + { + testcase("unparseable delegate SignerList is tefBAD_LEDGER"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const delegateAcct{"delegateAcct"}; + Account const ds1{"ds1"}; // on delegateAcct's own SignerList, not target's + Account const bob{"bob"}; + env.fund(XRP(10000), target, delegateAcct, ds1, bob); + env.close(); + + // Grant delegate Payment permission for target; give delegate its own + // SignerList so that isAuthorizedFor(delegateAccount) actually reads + // and deserializes it. Do not give target a SignerList: proposer ds1 + // must fail isAuthorizedFor(target) before ever reaching the delegate + // branch. + env(delegate::set(target, delegateAcct, {"Payment"})); + env(signers(delegateAcct, 1, {{ds1, 1}})); + env.close(); + + // Ticket first: createTicket closes, which would drop a later + // open-ledger overlay and restore a well-formed SignerList. + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + + // Corrupt delegate's SignerList in the open ledger overlay only. Do + // not close() afterward: a closed ledger would drop the overlay. + auto const delegateSignerListKeylet = keylet::signerList(delegateAcct.id()); + BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { + auto const sle = view.read(delegateSignerListKeylet); + if (!sle) + return false; + auto replacement = std::make_shared(*sle); + // Right inner name, but no sfAccount: deserialize calls + // getAccountID and throws (Field not found), which the catch + // maps to tefBAD_LEDGER. + STArray badEntries; + badEntries.pushBack(STObject{sfSignerEntry}); + replacement->setFieldArray(sfSignerEntries, badEntries); + view.rawReplace(replacement); + return true; + })); + BEAST_EXPECT(env.le(delegateSignerListKeylet)); + + json::Value tx = pay(target, bob, XRP(1)); + tx[sfDelegate.jsonName] = delegateAcct.human(); + env(proposal::create( + ds1, + proposal::unsignedPayload(env, tx, ticketSeq), + proposal::expiration(env, 100s)), + Ter(tefBAD_LEDGER), + proposal::verify::create()); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + + // The target account must be able to authorize a transaction through a + // SignerList, so a pseudo-account (here an AMM's) cannot be a target even + // though it exists on-ledger (On-Chain Cosigner spec §5.3.2.5). + void + testPseudoTarget(FeatureBitset features) + { + testcase("reject proposal targeting a pseudo-account"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const proposer{"proposer"}; + Account const alice{"alice"}; // the AMM creator + Account const gw{"gw"}; + Account const bob{"bob"}; + // NOLINTNEXTLINE(readability-identifier-naming) + auto const USD = gw["USD"]; + env.fund(XRP(10000), proposer, alice, gw, bob); + env.close(); + env.trust(USD(1'000'000), alice); + env.close(); + env(pay(gw, alice, USD(10'000))); + env.close(); + + AMM const amm(env, alice, XRP(1'000), USD(1'000), Ter(tesSUCCESS)); + env.close(); + + // A well-formed Payment whose target is the AMM's pseudo-account. + json::Value tx = pay(alice, bob, XRP(1)); + tx[jss::Account] = toBase58(amm.ammAccount()); + json::Value const proposedTx = proposal::unsignedPayload(env, tx, 1); + + env(proposal::create(proposer, proposedTx, proposal::expiration(env, 100s)), + Ter(tecNO_PERMISSION), + proposal::verify::create()); + env.close(); + } + + void + testCreate(FeatureBitset features) + { + testcase("create proposal object"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + // The proposed transaction is stored unsigned: no signature fields and + // an empty SigningPubKey. It is ticket-based so unrelated target account + // activity cannot invalidate it while signatures are collected. + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + + env(proposal::create(target, proposedTx, expiration), proposal::verify::create()); + env.close(); + + auto const sle = proposal::entry(env, target, targetTicketSeq); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(sle->getAccountID(sfOwner) == target.id()); + BEAST_EXPECT(sle->getFieldU32(sfExpiration) == expiration); + + auto const stored = sle->getFieldObject(sfProposedTransaction); + BEAST_EXPECT(stored.getAccountID(sfAccount) == target.id()); + BEAST_EXPECT(stored.getFieldU32(sfSequence) == 0); + BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq); + BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty()); + + // The proposal reserves several owner increments against the proposer, + // which proposal::verify::create() checks. Here target is both: it owns + // the Ticket used by the proposed transaction, and it owns the proposal + // itself since it is proposing for its own account. + BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kProposalOwnerCount); + } + + // A proposal carries a transaction of any type: what the proposal requires + // of the payload — unsigned, ticket-based, fee fixed — is independent of + // the transaction being proposed, so anything a target account's signer + // list could authorize can be proposed for it. + void + testOtherTransactionTypes(FeatureBitset features) + { + testcase("proposals for other transaction types"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + Account const gw{"gw"}; + // NOLINTNEXTLINE(readability-identifier-naming) + auto const USD = gw["USD"]; + env.fund(XRP(10000), target, bob, gw); + env.close(); + + // One payload per transaction type, each straight from the generator + // the ordinary tests for that type use. + std::vector const payloads{ + noop(target), // AccountSet + offer(target, USD(1), XRP(1)), // OfferCreate + trust(target, USD(1000)), // TrustSet + signers(target, 1, {{bob, 1}}), // SignerListSet + deposit::auth(target, bob), // DepositPreauth + token::mint(target, 0), // NFTokenMint + }; + + // A proposal is keyed by target and ticket, so each payload needs its + // own ticket. + std::uint32_t const firstTicketSeq = + proposal::createTicket(env, target, static_cast(payloads.size())); + std::uint32_t const expiration = proposal::expiration(env, 100s); + + for (std::size_t i = 0; i < payloads.size(); ++i) + { + std::uint32_t const ticketSeq = firstTicketSeq + static_cast(i); + env(proposal::create( + target, proposal::unsignedPayload(env, payloads[i], ticketSeq), expiration), + proposal::verify::create()); + env.close(); + } + + // target owns one Ticket per payload plus one proposal per payload. + BEAST_EXPECT( + ownerCount(env, target) == payloads.size() * (1 + proposal::kProposalOwnerCount)); + } + + // A proposed transaction may itself require an auxiliary co-signer beyond + // its own Account: a LoanSet's Counterparty, or the Sponsor of an + // account-level SponsorshipTransfer (On-Chain Cosigner spec §6.1, §6.6.3). That co-signature + // field is collected later via TransactionProposalSign, so — just like + // the ordinary signature fields — it must be absent, not required, at + // creation time. + void + testAuxiliaryCoSignatureTypes(FeatureBitset features) + { + testcase("proposal for a transaction type with an auxiliary co-signature"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const borrower{"borrower"}; // the target account, proposing for itself + Account const bob{"bob"}; // an arbitrary sponsor placeholder + + env.fund(XRP(10000), borrower, bob); + env.close(); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + + // LoanSet: the Counterparty's signature is collected later; it must + // not be required up front. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, borrower); + + json::Value const tx = + proposal::unsignedPayload(env, loan::set(borrower, uint256{1}, 1'000), ticketSeq); + + env(proposal::create(borrower, tx, expiration), proposal::verify::create()); + env.close(); + } + + // SponsorshipTransfer (account-level reserve sponsorship): the + // Sponsor's signature is likewise collected later. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, borrower); + + json::Value tx = sponsor::transfer(borrower, tfSponsorshipCreate); + tx[sfSponsor.getJsonName()] = bob.human(); + tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve; + + env(proposal::create( + borrower, proposal::unsignedPayload(env, tx, ticketSeq), expiration), + proposal::verify::create()); + env.close(); + } + } + + // The proposer holds the proposal's reserve until it is resolved. + void + testReserve(FeatureBitset features) + { + testcase("proposer reserve"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + proposal::authorizeProposer(env, target, alice); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + // Fund alice just short of the reserve the proposal requires. + env.fund( + env.current()->fees().accountReserve(proposal::kProposalOwnerCount, 1) - drops(1), + alice); + env.close(); + + std::uint32_t const expiration = proposal::expiration(env, 100s); + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + env(proposal::create(alice, proposedTx, expiration), + Ter(tecINSUFFICIENT_RESERVE), + proposal::verify::create()); + env.close(); + + env(pay(bob, alice, XRP(10))); + env.close(); + + env(proposal::create(alice, proposedTx, expiration), proposal::verify::create()); + env.close(); + } + + // The proposal's reserve can instead be sponsored: the reserve is charged + // to the sponsor's account, and the ledger object records the sponsor, the + // same as any other reserve-sponsorable object (TransactionProposalCreate + // is on the reserve-sponsorship allow-list). + void + testSponsoredReserve(FeatureBitset features) + { + testcase("proposer reserve sponsored"); + + using namespace jtx; + using namespace std::chrono_literals; + + // Reserve sponsorship requires the Sponsor amendment, independent of + // Cosign: with Cosign enabled but Sponsor disabled, a proposal that + // tries to attach a sponsor is rejected before it ever reaches the + // reserve-sponsorship allow-list. + { + Env env{*this, features - featureSponsor}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const backer{"backer"}; + env.fund(XRP(10000), alice, target, bob, backer); + env.close(); + proposal::authorizeProposer(env, target, alice); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), + sponsor::As(backer, spfSponsorReserve), + Sig(sfSponsorSignature, backer), + Ter(temDISABLED), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); + } + + Env env{*this, features}; + + Account const alice{"alice"}; // the proposer + Account const target{"target"}; // the account the proposal is for + Account const bob{"bob"}; + Account const backer{"backer"}; // sponsors alice's proposal reserve + + env.fund(XRP(10000), alice, target, bob, backer); + env.close(); + proposal::authorizeProposer(env, target, alice); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), + sponsor::As(backer, spfSponsorReserve), + Sig(sfSponsorSignature, backer), + proposal::verify::create()); + env.close(); + + auto const sle = proposal::entry(env, target, targetTicketSeq); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(sle->isFieldPresent(sfSponsor)); + BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer.id()); + + // alice still owns the proposal — her OwnerCount reflects that, same + // as an unsponsored proposal. What moves to the sponsor is the + // reserve requirement itself, tracked separately: alice's owner count + // is covered by backer's sponsorship rather than her own balance. + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, backer) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount); + BEAST_EXPECT(sponsoringOwnerCount(env, backer) == proposal::kProposalOwnerCount); + } + + // A proposal's sponsored reserve can be reassigned to a new sponsor + // through SponsorshipTransfer, the same as any other reserve-sponsored + // ledger entry. + void + testSponsorshipTransfer(FeatureBitset features) + { + testcase("proposer reserve sponsorship transferred"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; // the proposer + Account const target{"target"}; // the account the proposal is for + Account const bob{"bob"}; + Account const backer1{"backer1"}; // the original sponsor + Account const backer2{"backer2"}; // the new sponsor + + env.fund(XRP(10000), alice, target, bob, backer1, backer2); + env.close(); + proposal::authorizeProposer(env, target, alice); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), + sponsor::As(backer1, spfSponsorReserve), + Sig(sfSponsorSignature, backer1), + proposal::verify::create()); + env.close(); + + BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == proposal::kProposalOwnerCount); + BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == 0); + + Keylet const proposalKeylet = keylet::txProposal(target.id(), targetTicketSeq); + + env(sponsor::transfer(alice, tfSponsorshipReassign, proposalKeylet.key), + sponsor::As(backer2, spfSponsorReserve), + Sig(sfSponsorSignature, backer2)); + env.close(); + + auto const sle = proposal::entry(env, target, targetTicketSeq); + if (!BEAST_EXPECT(sle)) + return; + + BEAST_EXPECT(sle->isFieldPresent(sfSponsor)); + BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer2.id()); + + // alice's own OwnerCount is unaffected by the reassignment: only the + // sponsor of the redirected reserve changes. + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount); + BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == proposal::kProposalOwnerCount); + } + + // TransactionProposalCreate's own transaction fee can be sponsored like + // any other transaction's, independent of whether its reserve is + // sponsored (On-Chain Cosigner spec sponsorship is orthogonal to fee + // sponsorship). + void + testFeeSponsored(FeatureBitset features) + { + testcase("proposal creation fee sponsored"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; // the proposer, proposing for itself + Account const bob{"bob"}; + Account const backer{"backer"}; // sponsors target's transaction fee + + env.fund(XRP(10000), target, bob, backer); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + json::Value const proposedTx = + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); + + auto const targetBalance = env.balance(target); + auto const backerBalance = env.balance(backer); + // A generous fixed fee: the exact amount isn't the point of this + // test, only that the sponsor pays it instead of the proposer, so it + // should comfortably clear the minimum even under local fee escalation + // rather than assume the reference fee is some specific small value. + STAmount const feeAmt = XRP(1); + + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + Fee(feeAmt), + sponsor::As(backer, spfSponsorFee), + Sig(sfSponsorSignature, backer), + proposal::verify::create()); + env.close(); + + BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq)); + BEAST_EXPECT(env.balance(target) == targetBalance); + BEAST_EXPECT(env.balance(backer) == backerBalance - feeAmt); + } + + // A proposed Batch holds several inner transactions and the signatures of + // every account they touch, so it reserves more than an ordinary proposal. + void + testBatchReserve(FeatureBitset features) + { + testcase("proposed batch reserve"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + // Both inner transactions are the outer account's own, so no further + // signatures will be collected for them. + json::Value const proposedTx = proposal::unsignedBatch( + env, + target, + targetTicketSeq, + tfAllOrNothing, + {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)), + proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)}); + + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + + // target owns its own Ticket plus the batch proposal. + BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount); + } + + // A multi-account Batch is the primary motivating case (On-Chain Cosigner spec §10): its inner + // transactions touch accounts other than the outer one, so submitting it + // directly would require a BatchSigners entry per participant. A proposal is + // stored unsigned, so those signatures are collected on-ledger afterward and + // the signer-presence match is skipped at creation time (On-Chain Cosigner spec §5.3.1.2). + void + testMultiAccountBatch(FeatureBitset features) + { + testcase("proposed multi-account batch"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; // outer account of the batch, proposing for itself + Account const bob{"bob"}; // a distinct inner participant + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); + + // One inner from the outer account, one from bob: bob is a required + // signer, so a direct submission would need his BatchSigners entry. + json::Value const proposedTx = proposal::unsignedBatch( + env, + target, + targetTicketSeq, + tfAllOrNothing, + {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)), + proposal::innerTx(pay(bob, target, XRP(1)), env.seq(bob))}); + + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + + auto const sle = proposal::entry(env, target, targetTicketSeq); + if (!BEAST_EXPECT(sle)) + return; + + // The proposal is stored without any BatchSigners: the participants' + // signatures are collected later through TransactionProposalSign. + auto const stored = sle->getFieldObject(sfProposedTransaction); + BEAST_EXPECT(!stored.isFieldPresent(sfBatchSigners)); + // target owns its own Ticket plus the batch proposal. + BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount); + } + + // A proposed Batch's inner preflight must receive TapProposal, or an + // unsigned account-reserve SponsorshipTransfer is rejected at Create + // (On-Chain Cosigner spec §6.1.1: an inner Sponsor is a collectable + // signature slot). Other inner types that do not key on TapProposal keep + // their existing preflight result. + void + testProposedBatchInnerSponsorship(FeatureBitset features) + { + testcase("proposed batch inner account-reserve SponsorshipTransfer"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const target{"target"}; + Account const bob{"bob"}; // named as Sponsor; signature collected later + env.fund(XRP(10000), target, bob); + env.close(); + + auto unsignedInnerSponsorship = [&](Account const& account) { + json::Value tx = sponsor::transfer(account, tfSponsorshipCreate); + tx[sfSponsor.getJsonName()] = bob.human(); + tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve; + return tx; + }; + + // Payment (unaffected by TapProposal) plus an unsigned inner + // account-reserve SponsorshipTransfer. Create succeeds only if + // TapProposal reaches the inner. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + auto const seq = env.seq(target); + json::Value const proposedTx = proposal::unsignedBatch( + env, + target, + ticketSeq, + tfAllOrNothing, + {proposal::innerTx(pay(target, bob, XRP(1)), seq), + proposal::innerTx(unsignedInnerSponsorship(target), seq + 1)}); + + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); + } + + // Structural inner failures are unchanged: missing sfSponsor is still + // temMALFORMED in SponsorshipTransfer::preflight, collapsed by Batch + // to temINVALID_INNER_BATCH. TapProposal does not skip that. + { + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + auto const seq = env.seq(target); + json::Value const proposedTx = proposal::unsignedBatch( + env, + target, + ticketSeq, + tfAllOrNothing, + {proposal::innerTx(pay(target, bob, XRP(1)), seq), + proposal::innerTx(sponsor::transfer(target, tfSponsorshipCreate), seq + 1)}); + + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + Ter(temINVALID_INNER_BATCH), + proposal::verify::create()); + env.close(); + BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); + } + } + + void + run() override + { + using namespace jtx; + + FeatureBitset const all{testableAmendments()}; + + testReserveCounts(); + + // Preflight + testDisabled(all); + testRejectedPayload(all); + testRejectedSignatureFields(all); + + // Preclaim + testPreclaim(all); + testProposerAuthorization(all); + testCorruptSignerList(all); + testDelegatedProposedTx(all); + testDelegatedGranularProposedTx(all); + testCorruptDelegateSignerList(all); + testPseudoTarget(all); + + // Apply + testCreate(all); + testOtherTransactionTypes(all); + testAuxiliaryCoSignatureTypes(all); + testReserve(all); + testSponsoredReserve(all); + testSponsorshipTransfer(all); + testFeeSponsored(all); + testBatchReserve(all); + testMultiAccountBatch(all); + testProposedBatchInnerSponsorship(all); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalCreate, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/jtx/impl/proposal.cpp b/src/test/jtx/impl/proposal.cpp new file mode 100644 index 0000000000..58a6d7873d --- /dev/null +++ b/src/test/jtx/impl/proposal.cpp @@ -0,0 +1,352 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test::jtx::proposal { + +json::Value +create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration) +{ + json::Value jv; + jv[jss::TransactionType] = jss::TransactionProposalCreate; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.jsonName] = proposedTx; + jv[sfExpiration.jsonName] = expiration; + return jv; +} + +json::Value +unsignedPayload(Env const& env, json::Value tx, std::uint32_t ticketSeq) +{ + // Unsigned canonical form: an empty SigningPubKey and no signature fields + // at all. Signatures may only ever arrive through TransactionProposalSign. + tx[jss::SigningPubKey] = ""; + + // Ticket-based rather than sequence-based. Sequence is a required common + // field, so "no Sequence" is expressed as a Sequence of 0. + tx[jss::Sequence] = 0; + tx[sfTicketSequence.jsonName] = ticketSeq; + + // The target account pays this fee when the completed transaction is + // submitted, so it is fixed now. A fee already chosen by the caller stands. + fillFee(tx, *env.current()); + + return tx; +} + +json::Value +innerTx(json::Value tx, std::uint32_t seq) +{ + return batch::Inner{std::move(tx), seq}.getTxn(); +} + +json::Value +unsignedBatch( + Env const& env, + Account const& target, + std::uint32_t ticketSeq, + std::uint32_t flags, + std::vector const& inners, + std::optional numSigners) +{ + // Each inner account other than the outer one will contribute one + // BatchSigners entry once the signatures are collected, and the outer fee + // has to cover them from the start (Batch::calculateBaseFee). + std::uint32_t const signers = numSigners ? *numSigners : [&]() { + std::set participants; + for (auto const& inner : inners) + { + if (auto const account = inner[jss::Account].asString(); account != target.human()) + participants.insert(account); + } + return static_cast(participants.size()); + }(); + + json::Value jv = batch::outer( + target, + 0, + batch::calcBatchFee(env, signers, static_cast(inners.size())), + flags); + + json::Value& rawTransactions = jv[jss::RawTransactions]; + for (auto const& inner : inners) + rawTransactions[rawTransactions.size()][jss::RawTransaction] = inner; + + return unsignedPayload(env, std::move(jv), ticketSeq); +} + +void +authorizeProposer(Env& env, Account const& target, Account const& proposer) +{ + env(signers(target, 1, {{proposer, 1}})); + env.close(); +} + +std::uint32_t +createTicket(Env& env, Account const& account, std::uint32_t count) +{ + // The tickets a TicketCreate makes are numbered from the sequence that + // follows the one it consumes. + std::uint32_t const firstTicketSeq = env.seq(account) + 1; + env(ticket::create(account, count)); + env.close(); + return firstTicketSeq; +} + +std::uint32_t +expiration(Env& env, NetClock::duration delta) +{ + return (env.now() + delta).time_since_epoch().count(); +} + +SLE::const_pointer +entry(Env const& env, AccountID const& target, std::uint32_t ticketSeq) +{ + return env.le(keylet::txProposal(target, ticketSeq)); +} + +SLE::const_pointer +entry(Env const& env, Account const& target, std::uint32_t ticketSeq) +{ + return entry(env, target.id(), ticketSeq); +} + +namespace { + +// The keys an account's owner directory lists, each with the page it sits on. +// The pages are read directly, so a key with nothing behind it is still seen. +std::map +ownerDirKeys(ReadView const& view, AccountID const& account) +{ + std::map keys; + + auto const root = keylet::ownerDir(account); + std::uint64_t page = 0; + for (auto sle = view.read(root); sle;) + { + for (auto const& key : sle->getFieldV256(sfIndexes)) + keys.emplace(key, page); + + page = sle->getFieldU64(sfIndexNext); + if (page == 0) + break; + sle = view.read(keylet::page(root, page)); + } + + return keys; +} + +// Whether two reads of a ledger entry found it unchanged, or found nothing both +// times. Entries that are there are compared whole. +bool +unchanged(SLE::const_pointer const& before, SLE::const_pointer const& after) +{ + if (!before || !after) + return !before && !after; + + return before->key() == after->key() && + static_cast(*before) == static_cast(*after); +} + +// Whether a TransactionProposal entry may carry the field when it is created. +// A fresh proposal has gathered no signatures, so it carries nothing else — +// except a Sponsor, which a reserve-sponsored creation stamps on the entry from +// the start. +bool +isCreationField(SField const& field) +{ + return field == sfLedgerEntryType || field == sfFlags || field == sfOwner || + field == sfProposedTransaction || field == sfExpiration || field == sfOwnerNode || + field == sfPreviousTxnID || field == sfPreviousTxnLgrSeq || field == sfSponsor; +} + +} // namespace + +void +verify::Create::operator()(Env& env, JTx& jt) const +{ + // Only a TransactionProposalCreate carries the fields read below, and a + // condition that quietly checks nothing is worse than none at all. + if (jt.jv[jss::TransactionType].asString() != jss::TransactionProposalCreate.cStr()) + Throw("proposal::verify::create: not a TransactionProposalCreate"); + + // Funclets run before the transaction is applied, so everything read here + // is the state the effects are measured against. + auto const& proposedTx = jt.jv[sfProposedTransaction.jsonName]; + auto const parsedTarget = parseBase58(proposedTx[jss::Account].asString()); + + // A payload naming no usable target is a malformed case a test is making + // on purpose, and has no ledger effect to measure. + if (!parsedTarget) + return; + + auto const target = *parsedTarget; + auto const proposer = env.lookup(jt.jv[jss::Account].asString()); + auto const ticketSeq = proposedTx[sfTicketSequence.jsonName].asUInt(); + auto const expiration = jt.jv[sfExpiration.jsonName].asUInt(); + auto const cost = proposedTx[jss::TransactionType].asString() == jss::Batch.cStr() + ? kBatchProposalOwnerCount + : kProposalOwnerCount; + + std::optional reserveSponsor; + if (jt.jv.isMember(sfSponsor.jsonName) && + (jt.jv[sfSponsorFlags.jsonName].asUInt() & spfSponsorReserve) != 0) + reserveSponsor.emplace(env.lookup(jt.jv[sfSponsor.jsonName].asString())); + + // Every entry the transaction could touch, read whole, so what follows can + // say that nothing moved rather than that the fields we named did not. + auto const& view = *env.current(); + auto const proposalKeylet = keylet::txProposal(target, ticketSeq); + auto const ownerCountBefore = env.ownerCount(proposer); + auto const sponsoredOwnerCountBefore = env.sponsoredOwnerCount(proposer); + auto const sponsoringOwnerCountBefore = + reserveSponsor ? std::optional{env.sponsoringOwnerCount(*reserveSponsor)} : std::nullopt; + auto const proposalBefore = view.read(proposalKeylet); + auto const targetBefore = view.read(keylet::account(target)); + auto const ticketBefore = view.read(keylet::ticket(target, SeqProxy::rawTicket(ticketSeq))); + auto const proposerDirBefore = ownerDirKeys(view, proposer.id()); + auto const targetDirBefore = ownerDirKeys(view, target); + + jt.require.emplace_back([=](Env& applied) { + auto& test = applied.test; + auto const& view = *applied.current(); + + auto const created = isTesSuccess(applied.ter()); + + // The proposer owns the proposal even when another account covers its + // reserve. A proposed Batch costs more owner-count increments. + test.expect( + applied.ownerCount(proposer) == ownerCountBefore + (created ? cost : 0), + "proposal reserve"); + test.expect( + applied.sponsoredOwnerCount(proposer) == + sponsoredOwnerCountBefore + (created && reserveSponsor ? cost : 0), + "proposal sponsored owner count"); + if (reserveSponsor) + { + test.expect( + applied.sponsoringOwnerCount(*reserveSponsor) == + *sponsoringOwnerCountBefore + (created ? cost : 0), + "proposal sponsoring owner count"); + } + + // The target's ticket is left for the proposed transaction, including + // when the target is also the proposer. + test.expect( + unchanged( + ticketBefore, view.read(keylet::ticket(target, SeqProxy::rawTicket(ticketSeq)))), + "proposal target ticket"); + + // Nothing else of a distinct target's moves: the proposal belongs in + // the proposer's account and owner directory. + if (target != proposer.id()) + { + test.expect( + unchanged(targetBefore, view.read(keylet::account(target))), + "proposal target account"); + test.expect(ownerDirKeys(view, target) == targetDirBefore, "proposal target directory"); + } + + auto const sleProposal = view.read(proposalKeylet); + + if (!created) + { + // A create that did not succeed leaves the proposal as it found + // it, down to the last field. + test.expect(unchanged(proposalBefore, sleProposal), "proposal unchanged"); + + // Nor did the directory gain a listing for an entry that does not + // exist. + test.expect( + ownerDirKeys(view, proposer.id()) == proposerDirBefore, "proposal owner directory"); + return; + } + + // A successful create must have created the entry, not overwritten one + // that was already there. The checks below read the entry after the + // write, so they pass either way; this is what rules an overwrite out. + if (!test.expect(!proposalBefore, "proposal is new") || + !test.expect(sleProposal, "proposal entry")) + return; + + // What is on the ledger is what was submitted: a proposal is only worth + // collecting signatures against if the transaction it stores is the one + // proposed, so the payload is compared whole. + test.expect(sleProposal->getAccountID(sfOwner) == proposer.id(), "proposal owner"); + test.expect(sleProposal->getFieldU32(sfExpiration) == expiration, "proposal expiration"); + test.expect( + reserveSponsor ? sleProposal->isFieldPresent(sfSponsor) && + sleProposal->getAccountID(sfSponsor) == reserveSponsor->id() + : !sleProposal->isFieldPresent(sfSponsor), + "proposal sponsor"); + + auto const& stored = sleProposal->getFieldObject(sfProposedTransaction); + test.expect(stored == parse(proposedTx), "proposal payload"); + + // Unsigned canonical form, keyed by the target and ticket the payload + // names (On-Chain Cosigner spec §6.1). + test.expect( + xrpl::proposal::hasEmptySigningPubKey(stored) && + !xrpl::proposal::hasSignatureField(stored), + "proposal payload unsigned"); + test.expect( + stored.getAccountID(sfAccount) == target && + stored.getFieldU32(sfTicketSequence) == ticketSeq && + stored.getFieldU32(sfSequence) == 0, + "proposal payload target"); + + // Nothing beyond the fields a fresh proposal is created with. An + // STObject carries a placeholder for each optional field its format + // allows, so each field is asked whether it is really present. + test.expect(sleProposal->getFieldU32(sfFlags) == 0, "proposal flags"); + for (auto const& field : *sleProposal) + { + if (field.getSType() != STI_NOTPRESENT) + { + test.expect( + isCreationField(field.getFName()), + "proposal field " + field.getFName().getName()); + } + } + + // The proposal is listed in the proposer's directory on the page its + // OwnerNode names, and nothing else listed moved. + auto expectedDir = proposerDirBefore; + expectedDir.emplace(sleProposal->key(), sleProposal->getFieldU64(sfOwnerNode)); + test.expect(ownerDirKeys(view, proposer.id()) == expectedDir, "proposal owner directory"); + }); +} + +} // namespace xrpl::test::jtx::proposal diff --git a/src/test/jtx/proposal.h b/src/test/jtx/proposal.h new file mode 100644 index 0000000000..2703fd6046 --- /dev/null +++ b/src/test/jtx/proposal.h @@ -0,0 +1,191 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +/** + * @brief Helpers for constructing TransactionProposal test transactions. + */ +namespace xrpl::test::jtx::proposal { + +// The owner-reserve increments a proposal holds against its proposer. Tests +// spend these rather than repeating their values, so they follow the transactor +// instead of checking it; TransactionProposalCreate_test pins the values +// themselves, so a change to them has to be a deliberate one. +using xrpl::proposal::kBatchProposalOwnerCount; +using xrpl::proposal::kProposalOwnerCount; + +/** + * @brief Build a TransactionProposalCreate carrying an unsigned proposed + * transaction. + * + * @param proposer The account creating and paying the reserve for the proposal. + * @param proposedTx The proposed transaction, in unsigned canonical form; see + * unsignedPayload(). + * @param expiration Absolute time after which the proposal may no longer be + * completed. + * @return The TransactionProposalCreate JSON object. + */ +json::Value +create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration); + +/** + * @brief Conditions that check what a proposal transaction did to the ledger. + * + * Each is named for the generator it verifies, so a submission and its check + * read as a pair, and the transactions that later sign or complete a proposal + * get their own entries here rather than sharing one. + */ +namespace verify { + +/** + * @brief The condition returned by create(); see it for what is checked. + */ +class Create +{ +public: + /** + * @throws std::logic_error if attached to another transaction type. + */ + void + operator()(Env&, JTx&) const; +}; + +/** + * @brief Check the ledger effects a TransactionProposalCreate must have, + * whatever its outcome: on tesSUCCESS a new proposal holding what was + * submitted, listed in the proposer's directory and paid for by its reserve; + * otherwise nothing moved. Nothing of the target's moves either way. + * + * @code + * env(proposal::create(alice, payload, expiration), proposal::verify::create()); + * @endcode + */ +[[nodiscard]] inline Create +create() +{ + return Create{}; +} + +} // namespace verify + +/** + * @brief Put a transaction of any type into the form a proposal stores it in: + * unsigned and ticket-based, with the fee the target account will pay fixed + * now. + * + * This takes whatever any jtx generator produces — @c pay(), @c loan::set(), + * @c sponsor::transfer(), an outer @c Batch — and applies only what the + * proposal itself demands of the payload, so a test never hand-rolls those + * fields. It leaves the rest of @p tx untouched, which is what lets a test + * build one valid payload and then break exactly one rule of it. + * + * The payload is ticket-based so unrelated activity on the target account + * cannot invalidate it while signatures are collected. A Fee already set on + * @p tx is left alone, so a payload whose fee is not the base fee — a Batch, + * say — can carry its own; otherwise the fee is filled in the same way as for + * an ordinary submission. + * + * @param env The test environment providing ledger fee settings. + * @param tx The transaction to propose. + * @param ticketSeq A ticket sequence owned by @p tx's account. + * @return The proposed transaction JSON object. + */ +json::Value +unsignedPayload(Env const& env, json::Value tx, std::uint32_t ticketSeq); + +/** + * @brief Put a transaction into the form an inner transaction of a proposed + * Batch takes, as @c batch::Inner does for an ordinary Batch. + * + * @param tx The transaction to nest. + * @param seq The sequence number of @p tx's own account. + * @return The inner transaction JSON object. + */ +json::Value +innerTx(json::Value tx, std::uint32_t seq); + +/** + * @brief An unsigned outer Batch payload holding @p inners. + * + * A proposed Batch stores no BatchSigners — the participants' signatures are + * collected on-ledger afterwards — but its fee is fixed now and must already + * cover them. By default one signer is assumed for each inner account other + * than @p target, which is what those participants will contribute. + * + * @param env The test environment providing ledger fee settings. + * @param target The outer account of the Batch, and the proposal's target. + * @param ticketSeq A ticket sequence owned by @p target. + * @param flags The Batch mode flags, e.g. @c tfAllOrNothing. + * @param inners The inner transactions; see innerTx(). + * @param numSigners Overrides the number of signatures the fee accounts for. + * @return The proposed Batch JSON object. + */ +json::Value +unsignedBatch( + Env const& env, + Account const& target, + std::uint32_t ticketSeq, + std::uint32_t flags, + std::vector const& inners, + std::optional numSigners = std::nullopt); + +/** + * @brief Give @p proposer a place on @p target's SignerList, so it may + * create proposals against @p target. + * + * Only the target account itself, or an account on its SignerList, may + * create a TransactionProposalCreate against it (On-Chain Cosigner V1 + * authorization). Sets a minimal one-signer, quorum-1 list; the quorum + * itself is irrelevant here since it only governs the proposed + * transaction's own completion, not who may propose it. + * + * @param env The test environment. + * @param target The account whose SignerList is set. + * @param proposer The account to add to it. + */ +void +authorizeProposer(Env& env, Account const& target, Account const& proposer); + +/** + * @brief Create tickets for a proposal to be built against, and close the + * ledger. + * + * @param env The test environment. + * @param account The account that will own the tickets, i.e. the target of the + * proposals to come. + * @param count How many tickets to create. + * @return The first ticket sequence created; the rest follow it. + */ +std::uint32_t +createTicket(Env& env, Account const& account, std::uint32_t count = 1); + +/** + * @brief An absolute expiration @p delta past the environment's current time. + * + * @c expiration(env, 0s) is an expiration that has already passed. + */ +std::uint32_t +expiration(Env& env, NetClock::duration delta); + +/** + * @brief The proposal stored against a target account's ticket. + * @return empty if no such proposal exists. + */ +[[nodiscard]] SLE::const_pointer +entry(Env const& env, AccountID const& target, std::uint32_t ticketSeq); +[[nodiscard]] SLE::const_pointer +entry(Env const& env, Account const& target, std::uint32_t ticketSeq); + +} // namespace xrpl::test::jtx::proposal diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp index 1450709f59..d9ad957db1 100644 --- a/src/test/rpc/AccountObjects_test.cpp +++ b/src/test/rpc/AccountObjects_test.cpp @@ -9,6 +9,7 @@ #include // IWYU pragma: keep #include #include +#include #include #include #include @@ -35,6 +36,7 @@ #include #include +#include // IWYU pragma: keep #include #include #include @@ -1699,6 +1701,64 @@ public: BEAST_EXPECT(res[jss::result].isMember(jss::marker)); } + // A TransactionProposal blocks AccountDelete (it is not in + // nonObligationDeleter) but was omitted from kDeletionBlockers, so + // deletion_blockers_only reported a clean directory. Tickets on the + // same account are auto-removed at delete time and must stay omitted. + void + testDeletionBlockersProposal() + { + testcase("deletion_blockers_only includes TransactionProposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, testableAmendments()}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + env.close(); + + std::uint32_t const ticketSeq = proposal::createTicket(env, alice); + env(proposal::create( + alice, + proposal::unsignedPayload(env, pay(alice, bob, XRP(1)), ticketSeq), + proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + + json::Value params; + params[jss::account] = alice.human(); + params[jss::deletion_blockers_only] = true; + params[jss::ledger_index] = "validated"; + + { + auto const resp = env.rpc("json", "account_objects", to_string(params)); + auto const& aobjs = resp[jss::result][jss::account_objects]; + if (BEAST_EXPECT(aobjs.isArray() && aobjs.size() == 1)) + { + BEAST_EXPECT(aobjs[0u][sfLedgerEntryType.jsonName] == jss::TransactionProposal); + BEAST_EXPECT(aobjs[0u][sfOwner.jsonName] == alice.human()); + } + } + + { + params[jss::type] = jss::transaction_proposal; + auto const resp = env.rpc("json", "account_objects", to_string(params)); + auto const& aobjs = resp[jss::result][jss::account_objects]; + if (BEAST_EXPECT(aobjs.isArray() && aobjs.size() == 1)) + BEAST_EXPECT(aobjs[0u][sfLedgerEntryType.jsonName] == jss::TransactionProposal); + } + + { + params[jss::type] = jss::check; + auto const resp = env.rpc("json", "account_objects", to_string(params)); + auto const& aobjs = resp[jss::result][jss::account_objects]; + BEAST_EXPECT(aobjs.isArray() && aobjs.size() == 0); + } + } + void run() override { @@ -1711,6 +1771,7 @@ public: testAccountObjectMarker(); testSponsoredFilter(); testAccountObjectDoesntShowCancelledOffers(); + testDeletionBlockersProposal(); } }; diff --git a/src/test/rpc/LedgerEntry_test.cpp b/src/test/rpc/LedgerEntry_test.cpp index 24dde05ce1..3abe83153b 100644 --- a/src/test/rpc/LedgerEntry_test.cpp +++ b/src/test/rpc/LedgerEntry_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -327,6 +328,7 @@ class LedgerEntry_test : public beast::unit_test::Suite FieldType const typeID, std::string const& expectedError, bool required = true, + std::optional typeNameOverride = std::nullopt, std::source_location const location = std::source_location::current()) { forAllApiVersions([&, this](unsigned apiVersion) { @@ -349,8 +351,8 @@ class LedgerEntry_test : public beast::unit_test::Suite correctRequest[fieldName] = fieldValue; json::Value const jrr = env.rpc( apiVersion, "json", "ledger_entry", to_string(correctRequest))[jss::result]; - auto const expectedErrMsg = - rpc::expectedFieldMessage(fieldName, getTypeName(typeID)); + auto const expectedErrMsg = rpc::expectedFieldMessage( + fieldName, typeNameOverride.value_or(getTypeName(typeID))); checkErrorValue(jrr, expectedError, expectedErrMsg, location); }; @@ -426,6 +428,7 @@ class LedgerEntry_test : public beast::unit_test::Suite FieldType::HashField, "malformedRequest", true, + std::nullopt, location); } @@ -441,6 +444,7 @@ class LedgerEntry_test : public beast::unit_test::Suite test::jtx::Env& env, json::StaticString const& parentField, std::vector const& subfields, + std::optional parentTypeNameOverride = std::nullopt, std::source_location const location = std::source_location::current()) { testMalformedField( @@ -450,6 +454,7 @@ class LedgerEntry_test : public beast::unit_test::Suite FieldType::HashOrObjectField, "malformedRequest", true, + parentTypeNameOverride, location); json::Value correctOutput; @@ -2027,6 +2032,87 @@ class LedgerEntry_test : public beast::unit_test::Suite } } + void + testTransactionProposal() + { + testcase("TransactionProposal"); + using namespace test::jtx; + using namespace std::literals::chrono_literals; + + Env env{*this}; + + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + // A ticket for the proposal to be built against, and the proposal + // itself (an unsigned Payment payload). + std::uint32_t const ticketSeq = proposal::createTicket(env, target); + env(proposal::create( + target, + proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq), + proposal::expiration(env, 100s)), + proposal::verify::create()); + env.close(); + + std::string const ledgerHash{to_string(env.closed()->header().hash)}; + auto const proposalIndex = to_string(keylet::txProposal(target.id(), ticketSeq).key); + + { + // Request by target account and ticket sequence. + json::Value jvParams; + jvParams[jss::transaction_proposal][jss::account] = target.human(); + jvParams[jss::transaction_proposal][jss::ticket_seq] = ticketSeq; + jvParams[jss::ledger_hash] = ledgerHash; + auto const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::TransactionProposal); + BEAST_EXPECT(proposalIndex == jrr[jss::node][jss::index].asString()); + } + { + // Request by object index (hex string form). + json::Value jvParams; + jvParams[jss::transaction_proposal] = proposalIndex; + jvParams[jss::ledger_hash] = ledgerHash; + auto const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::TransactionProposal); + BEAST_EXPECT(proposalIndex == jrr[jss::node][jss::index].asString()); + } + { + // No proposal exists against this (account, ticket_seq) pair. + json::Value jvParams; + jvParams[jss::transaction_proposal][jss::account] = target.human(); + jvParams[jss::transaction_proposal][jss::ticket_seq] = ticketSeq + 1; + jvParams[jss::ledger_hash] = ledgerHash; + auto const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "entryNotFound", "Entry not found."); + } + { + // Lookup by an index of the wrong entry type. + json::Value jvParams; + jvParams[jss::transaction_proposal] = to_string(keylet::account(target).key); + jvParams[jss::ledger_hash] = ledgerHash; + auto const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "unexpectedLedgerType", "Unexpected ledger type."); + } + + { + // Malformed cases (missing / wrong-type subfields, and a + // non-object non-hex-string parent value). Once the parent has + // been shown not to be an object, parseTransactionProposal names + // "hex string" — not "hex string or object" — as the form still + // on the table. + runLedgerEntryTest( + env, + jss::transaction_proposal, + { + {.fieldName = jss::account, .malformedErrorMsg = "malformedAddress"}, + {.fieldName = jss::ticket_seq, .malformedErrorMsg = "malformedRequest"}, + }, + "hex string"); + } + } + void testDID() { @@ -2747,6 +2833,7 @@ public: testSignerList(); testSponsorship(); testTicket(); + testTransactionProposal(); testDID(); testInvalidOracleLedgerEntry(); testOracleLedgerEntry(); diff --git a/src/tests/libxrpl/ledger/SLEBase.cpp b/src/tests/libxrpl/ledger/SLEBase.cpp index f721ea760d..231021aa12 100644 --- a/src/tests/libxrpl/ledger/SLEBase.cpp +++ b/src/tests/libxrpl/ledger/SLEBase.cpp @@ -33,6 +33,7 @@ #include // IWYU pragma: keep #include // IWYU pragma: keep #include +#include // IWYU pragma: keep #include // IWYU pragma: keep #include // IWYU pragma: keep #include // IWYU pragma: keep diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp new file mode 100644 index 0000000000..f22554519c --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp @@ -0,0 +1,223 @@ +// Auto-generated unit tests for ledger entry TransactionProposal + + +#include + +#include + +#include +#include +#include + +#include + +namespace xrpl::ledger_entries { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed for both the +// builder's STObject and the wrapper's SLE. +TEST(TransactionProposalTests, BuilderSettersRoundTrip) +{ + uint256 const index{1u}; + + auto const previousTxnIDValue = canonical_UINT256(); + auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const ownerValue = canonical_ACCOUNT(); + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + + TransactionProposalBuilder builder{ + previousTxnIDValue, + previousTxnLgrSeqValue, + ownerValue, + proposedTransactionValue, + expirationValue, + ownerNodeValue + }; + + + builder.setLedgerIndex(index); + builder.setFlags(0x1u); + + EXPECT_TRUE(builder.validate()); + + auto const entry = builder.build(index); + + EXPECT_TRUE(entry.validate()); + + { + auto const& expected = previousTxnIDValue; + auto const actual = entry.getPreviousTxnID(); + expectEqualField(expected, actual, "sfPreviousTxnID"); + } + + { + auto const& expected = previousTxnLgrSeqValue; + auto const actual = entry.getPreviousTxnLgrSeq(); + expectEqualField(expected, actual, "sfPreviousTxnLgrSeq"); + } + + { + auto const& expected = ownerValue; + auto const actual = entry.getOwner(); + expectEqualField(expected, actual, "sfOwner"); + } + + { + auto const& expected = proposedTransactionValue; + auto const actual = entry.getProposedTransaction(); + expectEqualField(expected, actual, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + auto const actual = entry.getExpiration(); + expectEqualField(expected, actual, "sfExpiration"); + } + + { + auto const& expected = ownerNodeValue; + auto const actual = entry.getOwnerNode(); + expectEqualField(expected, actual, "sfOwnerNode"); + } + + EXPECT_TRUE(entry.hasLedgerIndex()); + auto const ledgerIndex = entry.getLedgerIndex(); + ASSERT_TRUE(ledgerIndex.has_value()); + EXPECT_EQ(*ledgerIndex, index); + EXPECT_EQ(entry.getKey(), index); +} + +// 2 & 4) Start from an SLE, set fields directly on it, construct a builder +// from that SLE, build a new wrapper, and verify all fields (and validate()). +TEST(TransactionProposalTests, BuilderFromSleRoundTrip) +{ + uint256 const index{2u}; + + auto const previousTxnIDValue = canonical_UINT256(); + auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const ownerValue = canonical_ACCOUNT(); + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + + auto sle = std::make_shared(TransactionProposal::entryType, index); + + sle->at(sfPreviousTxnID) = previousTxnIDValue; + sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; + sle->at(sfOwner) = ownerValue; + sle->setFieldObject(sfProposedTransaction, proposedTransactionValue); + sle->at(sfExpiration) = expirationValue; + sle->at(sfOwnerNode) = ownerNodeValue; + + TransactionProposalBuilder builderFromSle{sle}; + EXPECT_TRUE(builderFromSle.validate()); + + auto const entryFromBuilder = builderFromSle.build(index); + + TransactionProposal entryFromSle{sle}; + EXPECT_TRUE(entryFromBuilder.validate()); + EXPECT_TRUE(entryFromSle.validate()); + + { + auto const& expected = previousTxnIDValue; + + auto const fromSle = entryFromSle.getPreviousTxnID(); + auto const fromBuilder = entryFromBuilder.getPreviousTxnID(); + + expectEqualField(expected, fromSle, "sfPreviousTxnID"); + expectEqualField(expected, fromBuilder, "sfPreviousTxnID"); + } + + { + auto const& expected = previousTxnLgrSeqValue; + + auto const fromSle = entryFromSle.getPreviousTxnLgrSeq(); + auto const fromBuilder = entryFromBuilder.getPreviousTxnLgrSeq(); + + expectEqualField(expected, fromSle, "sfPreviousTxnLgrSeq"); + expectEqualField(expected, fromBuilder, "sfPreviousTxnLgrSeq"); + } + + { + auto const& expected = ownerValue; + + auto const fromSle = entryFromSle.getOwner(); + auto const fromBuilder = entryFromBuilder.getOwner(); + + expectEqualField(expected, fromSle, "sfOwner"); + expectEqualField(expected, fromBuilder, "sfOwner"); + } + + { + auto const& expected = proposedTransactionValue; + + auto const fromSle = entryFromSle.getProposedTransaction(); + auto const fromBuilder = entryFromBuilder.getProposedTransaction(); + + expectEqualField(expected, fromSle, "sfProposedTransaction"); + expectEqualField(expected, fromBuilder, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + + auto const fromSle = entryFromSle.getExpiration(); + auto const fromBuilder = entryFromBuilder.getExpiration(); + + expectEqualField(expected, fromSle, "sfExpiration"); + expectEqualField(expected, fromBuilder, "sfExpiration"); + } + + { + auto const& expected = ownerNodeValue; + + auto const fromSle = entryFromSle.getOwnerNode(); + auto const fromBuilder = entryFromBuilder.getOwnerNode(); + + expectEqualField(expected, fromSle, "sfOwnerNode"); + expectEqualField(expected, fromBuilder, "sfOwnerNode"); + } + + EXPECT_EQ(entryFromSle.getKey(), index); + EXPECT_EQ(entryFromBuilder.getKey(), index); +} + +// 3) Verify wrapper throws when constructed from wrong ledger entry type. +TEST(TransactionProposalTests, WrapperThrowsOnWrongEntryType) +{ + uint256 const index{3u}; + + // Build a valid ledger entry of a different type + // Ticket requires: Account, OwnerNode, TicketSequence, PreviousTxnID, PreviousTxnLgrSeq + // Check requires: Account, Destination, SendMax, Sequence, OwnerNode, DestinationNode, PreviousTxnID, PreviousTxnLgrSeq + TicketBuilder wrongBuilder{ + canonical_ACCOUNT(), + canonical_UINT64(), + canonical_UINT32(), + canonical_UINT256(), + canonical_UINT32()}; + auto wrongEntry = wrongBuilder.build(index); + + EXPECT_THROW(TransactionProposal{wrongEntry.getSle()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong ledger entry type. +TEST(TransactionProposalTests, BuilderThrowsOnWrongEntryType) +{ + uint256 const index{4u}; + + // Build a valid ledger entry of a different type + TicketBuilder wrongBuilder{ + canonical_ACCOUNT(), + canonical_UINT64(), + canonical_UINT32(), + canonical_UINT256(), + canonical_UINT32()}; + auto wrongEntry = wrongBuilder.build(index); + + EXPECT_THROW(TransactionProposalBuilder{wrongEntry.getSle()}, std::runtime_error); +} + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp new file mode 100644 index 0000000000..58d6b2d0e7 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp @@ -0,0 +1,162 @@ +// Auto-generated unit tests for transaction TransactionProposalCreate + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsTransactionProposalCreateTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCreate")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + + TransactionProposalCreateBuilder builder{ + accountValue, + proposedTransactionValue, + expirationValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = proposedTransactionValue; + auto const actual = tx.getProposedTransaction(); + expectEqualField(expected, actual, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + auto const actual = tx.getExpiration(); + expectEqualField(expected, actual, "sfExpiration"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsTransactionProposalCreateTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCreateFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + + // Build an initial transaction + TransactionProposalCreateBuilder initialBuilder{ + accountValue, + proposedTransactionValue, + expirationValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + TransactionProposalCreateBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = proposedTransactionValue; + auto const actual = rebuiltTx.getProposedTransaction(); + expectEqualField(expected, actual, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + auto const actual = rebuiltTx.getExpiration(); + expectEqualField(expected, actual, "sfExpiration"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsTransactionProposalCreateTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(TransactionProposalCreate{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsTransactionProposalCreateTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(TransactionProposalCreateBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/tx/ProposalHelpers.cpp b/src/tests/libxrpl/tx/ProposalHelpers.cpp new file mode 100644 index 0000000000..1b148535fe --- /dev/null +++ b/src/tests/libxrpl/tx/ProposalHelpers.cpp @@ -0,0 +1,116 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace xrpl::test { + +namespace { + +// A bare STObject carrying only sfTransactionType. isValidProposal accepts an +// STObject (not an STTx) precisely so that this file's defense-in-depth +// checks can be tested without the surrounding STTx format validation. +inline STObject +txOfType(std::uint16_t txType) +{ + STObject tx(sfGeneric); + tx.setFieldU16(sfTransactionType, txType); + return tx; +} + +// A bare Batch STObject wrapping the given inner transaction as its single +// sfRawTransactions entry. +inline STObject +batchWrapping(STObject inner) +{ + STArray rawTxns(sfRawTransactions); + rawTxns.push_back(std::move(inner)); + + STObject batch(sfGeneric); + batch.setFieldU16(sfTransactionType, ttBATCH); + batch.setFieldArray(sfRawTransactions, rawTxns); + return batch; +} + +} // namespace + +// The happy path — an ordinary Payment is independently submittable. +TEST(ProposalHelpers, plain_payment_is_valid) +{ + EXPECT_TRUE(proposal::isValidProposal(txOfType(ttPAYMENT))); +} + +// A nested TransactionProposalCreate. In practice STTx construction rejects +// this earlier (the payload lacks TransactionProposalCreate's own template +// fields), but the defense here re-checks that guard so the two cannot +// drift apart. +TEST(ProposalHelpers, nested_proposal_is_rejected) +{ + EXPECT_FALSE(proposal::isValidProposal(txOfType(ttTRANSACTION_PROPOSAL_CREATE))); +} + +// Any pseudo-transaction — see STTx::isPseudoTx. Also normally caught earlier +// by STTx construction / preflight0. +TEST(ProposalHelpers, pseudo_tx_is_rejected) +{ + EXPECT_FALSE(proposal::isValidProposal(txOfType(ttAMENDMENT))); + EXPECT_FALSE(proposal::isValidProposal(txOfType(ttFEE))); + EXPECT_FALSE(proposal::isValidProposal(txOfType(ttUNL_MODIFY))); +} + +// tfInnerBatchTxn marks a transaction as an inner leg of an enclosing Batch, +// so it must never stand on its own as a proposed transaction. preflight0 +// rejects the standalone case with temINVALID_INNER_BATCH before we get +// here; the guard is re-checked so the two cannot drift apart. +TEST(ProposalHelpers, inner_batch_flag_is_rejected) +{ + STObject tx = txOfType(ttPAYMENT); + tx.setFieldU32(sfFlags, tfInnerBatchTxn); + EXPECT_FALSE(proposal::isValidProposal(tx)); +} + +// A Flags value that is present but does not include tfInnerBatchTxn must +// not be rejected — the check is bit-specific, not "any flag present". +TEST(ProposalHelpers, other_flags_are_accepted) +{ + STObject tx = txOfType(ttPAYMENT); + tx.setFieldU32(sfFlags, tfFullyCanonicalSig); + EXPECT_TRUE(proposal::isValidProposal(tx)); +} + +// A Batch wrapping a plain inner is fine — the loop is only there to catch +// specifically forbidden inner types. +TEST(ProposalHelpers, batch_with_plain_inner_is_valid) +{ + EXPECT_TRUE(proposal::isValidProposal(batchWrapping(txOfType(ttPAYMENT)))); +} + +// A Batch whose inner is itself a proposal must be rejected. +TEST(ProposalHelpers, batch_with_nested_proposal_inner_is_rejected) +{ + EXPECT_FALSE(proposal::isValidProposal(batchWrapping(txOfType(ttTRANSACTION_PROPOSAL_CREATE)))); +} + +// A Batch whose inner is a pseudo-transaction must be rejected. +TEST(ProposalHelpers, batch_with_pseudo_inner_is_rejected) +{ + EXPECT_FALSE(proposal::isValidProposal(batchWrapping(txOfType(ttAMENDMENT)))); +} + +// A Batch with no sfRawTransactions field skips the inner-loop entirely. +// Not something the transactor would ever emit, but the branch exists in the +// helper (the field is optional at the STObject level) and should hold. +TEST(ProposalHelpers, batch_without_raw_transactions_is_valid) +{ + EXPECT_TRUE(proposal::isValidProposal(txOfType(ttBATCH))); +} + +} // namespace xrpl::test diff --git a/src/xrpld/rpc/handlers/account/AccountObjects.cpp b/src/xrpld/rpc/handlers/account/AccountObjects.cpp index e855ed65e6..600f41d2e7 100644 --- a/src/xrpld/rpc/handlers/account/AccountObjects.cpp +++ b/src/xrpld/rpc/handlers/account/AccountObjects.cpp @@ -314,6 +314,7 @@ doAccountObjects(rpc::JsonContext& context) {.name = jss::permissioned_domain, .type = ltPERMISSIONED_DOMAIN}, {.name = jss::vault, .type = ltVAULT}, {.name = jss::sponsorship, .type = ltSPONSORSHIP}, + {.name = jss::transaction_proposal, .type = ltTRANSACTION_PROPOSAL}, }; typeFilter.emplace(); diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 5271720b34..f56a8825bb 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -751,6 +751,35 @@ parseSponsorship( return keylet::sponsorship(*sponsorID, *sponseeID).key; } +static std::expected +parseTransactionProposal( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + // In the non-object branch the caller must supply the proposal ID as a + // hex string (the object form is the {account, ticket_seq} pair handled + // below). Passing "hex string" here — rather than the more general + // "hex string or object" default — makes the error message name the + // exact form still on the table once an object has been ruled out. + if (!params.isObject()) + return parseObjectID(params, fieldName, "hex string"); + + auto const targetID = + ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAddress"); + if (!targetID) + return std::unexpected(targetID.error()); + + // The proposed transaction's TicketSequence (a proposed transaction is + // ticket-only), mirroring how parseTicket looks up a Ticket object. + auto const ticketSequence = + ledger_entry_helpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest"); + if (!ticketSequence) + return std::unexpected(ticketSequence.error()); + + return keylet::txProposal(*targetID, *ticketSequence).key; +} + static std::expected parseTicket( json::Value const& params,