From 7f8d24bda08bb48550a2f3d644f79be92526e467 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Fri, 24 Jul 2026 17:53:42 -0400 Subject: [PATCH 01/21] Add TransactionProposal ledger object: format, hash index, and fields --- include/xrpl/protocol/Indexes.h | 21 ++ .../xrpl/protocol/detail/ledger_entries.macro | 18 ++ include/xrpl/protocol/detail/sfields.macro | 2 + .../ledger_entries/TransactionProposal.h | 242 ++++++++++++++++++ src/libxrpl/protocol/Indexes.cpp | 9 + .../TransactionProposalTests.cpp | 223 ++++++++++++++++ src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp | 24 ++ 7 files changed, 539 insertions(+) create mode 100644 include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h create mode 100644 src/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 07493da0bd6..221796042c7 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -187,6 +187,27 @@ check(uint256 const& key) noexcept } /** @} */ +/** + * A TransactionProposal + * + * target and ticketSequence are the proposed transaction's Account and + * TicketSequence value (a proposed transaction is ticket-only). The owner + * (proposer) is deliberately not part of the key: the identity is + * (target, ticketSequence) so that the entry can be located — and + * automatically deleted — from the target transaction alone when it consumes + * that ticket, without knowing who proposed it. + */ +/** @{ */ +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/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2d..357b0428ded 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -636,5 +636,23 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ {sfSponseeNode, SoeRequired}, })) +/** A ledger object holding a pending multi-signature proposal. + + The proposed transaction is stored unsigned in RawTransaction; collected + signatures accumulate inside its Signers field. Once the collected weight + meets the target account's quorum, the stored transaction is a fully + signed transaction that anyone may copy and submit. + + \sa keylet::txProposal + */ +LEDGER_ENTRY(ltTRANSACTION_PROPOSAL, 0x0091, TransactionProposal, transaction_proposal, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwner, SoeRequired}, + {sfRawTransaction, 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 4ef76c8b759..7d5c7e838bd 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -216,6 +216,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) @@ -357,6 +358,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) 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 00000000000..546834e2ca2 --- /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 sfRawTransaction (SoeRequired) + * @note This is an untyped field (unknown). + * @return The field value. + */ + [[nodiscard]] + STObject + getRawTransaction() const + { + return this->sle_->getFieldObject(sfRawTransaction); + } + + /** + * @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 rawTransaction The sfRawTransaction 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& rawTransaction,std::decay_t const& expiration,std::decay_t const& ownerNode) + : LedgerEntryBuilderBase(ltTRANSACTION_PROPOSAL) + { + setPreviousTxnID(previousTxnID); + setPreviousTxnLgrSeq(previousTxnLgrSeq); + setOwner(owner); + setRawTransaction(rawTransaction); + 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 sfRawTransaction (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalBuilder& + setRawTransaction(STObject const& value) + { + object_.setFieldObject(sfRawTransaction, 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/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 95416d0f2ac..e4f01938818 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', @@ -358,6 +359,14 @@ check(AccountID const& id, std::uint32_t seq) noexcept return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq)}; } +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/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/TransactionProposalTests.cpp new file mode 100644 index 00000000000..b2fa5308f5f --- /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 rawTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + + TransactionProposalBuilder builder{ + previousTxnIDValue, + previousTxnLgrSeqValue, + ownerValue, + rawTransactionValue, + 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 = rawTransactionValue; + auto const actual = entry.getRawTransaction(); + expectEqualField(expected, actual, "sfRawTransaction"); + } + + { + 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 rawTransactionValue = 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(sfRawTransaction, rawTransactionValue); + 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 = rawTransactionValue; + + auto const fromSle = entryFromSle.getRawTransaction(); + auto const fromBuilder = entryFromBuilder.getRawTransaction(); + + expectEqualField(expected, fromSle, "sfRawTransaction"); + expectEqualField(expected, fromBuilder, "sfRawTransaction"); + } + + { + 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/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 784be779bbe..536bddefcd1 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -741,6 +741,30 @@ parseSponsorship( return keylet::sponsorship(*sponsorID, *sponseeID).key; } +static std::expected +parseTransactionProposal( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + return parseObjectID(params, fieldName, "hex string"); + + auto const targetID = + LedgerEntryHelpers::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 = + LedgerEntryHelpers::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, From 489d853dc405106fcf806e9fcc2c7cbb0ff986cd Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Mon, 27 Jul 2026 10:55:43 -0400 Subject: [PATCH 02/21] comment --- include/xrpl/protocol/Indexes.h | 7 ------- 1 file changed, 7 deletions(-) diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 221796042c7..825c027f658 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -189,13 +189,6 @@ check(uint256 const& key) noexcept /** * A TransactionProposal - * - * target and ticketSequence are the proposed transaction's Account and - * TicketSequence value (a proposed transaction is ticket-only). The owner - * (proposer) is deliberately not part of the key: the identity is - * (target, ticketSequence) so that the entry can be located — and - * automatically deleted — from the target transaction alone when it consumes - * that ticket, without knowing who proposed it. */ /** @{ */ Keylet From 966f05f601c5971d17ffa73ef6b1df7bee288a5a Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Mon, 27 Jul 2026 11:19:33 -0400 Subject: [PATCH 03/21] naming --- .../xrpl/protocol/detail/ledger_entries.macro | 10 ++++---- include/xrpl/protocol/detail/sfields.macro | 1 + .../ledger_entries/TransactionProposal.h | 18 +++++++------- .../TransactionProposalTests.cpp | 24 +++++++++---------- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 357b0428ded..516ec47a32e 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -638,10 +638,10 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ /** A ledger object holding a pending multi-signature proposal. - The proposed transaction is stored unsigned in RawTransaction; collected - signatures accumulate inside its Signers field. Once the collected weight - meets the target account's quorum, the stored transaction is a fully - signed transaction that anyone may copy and submit. + The proposed transaction is stored unsigned in ProposedTransaction; + collected signatures accumulate inside its Signers field. Once the + collected weight meets the target account's quorum, the stored transaction + is a fully signed transaction that anyone may copy and submit. \sa keylet::txProposal */ @@ -649,7 +649,7 @@ LEDGER_ENTRY(ltTRANSACTION_PROPOSAL, 0x0091, TransactionProposal, transaction_pr {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfOwner, SoeRequired}, - {sfRawTransaction, SoeRequired}, + {sfProposedTransaction, SoeRequired}, {sfExpiration, SoeRequired}, {sfOwnerNode, SoeRequired}, })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 7d5c7e838bd..493f1e1b365 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -424,6 +424,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_autogen/ledger_entries/TransactionProposal.h b/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h index 546834e2ca2..905139bf372 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h +++ b/include/xrpl/protocol_autogen/ledger_entries/TransactionProposal.h @@ -79,15 +79,15 @@ class TransactionProposal : public LedgerEntryBase } /** - * @brief Get sfRawTransaction (SoeRequired) + * @brief Get sfProposedTransaction (SoeRequired) * @note This is an untyped field (unknown). * @return The field value. */ [[nodiscard]] STObject - getRawTransaction() const + getProposedTransaction() const { - return this->sle_->getFieldObject(sfRawTransaction); + return this->sle_->getFieldObject(sfProposedTransaction); } /** @@ -128,17 +128,17 @@ class TransactionProposalBuilder : public LedgerEntryBuilderBase const& previousTxnID,std::decay_t const& previousTxnLgrSeq,std::decay_t const& owner,STObject const& rawTransaction,std::decay_t const& expiration,std::decay_t const& ownerNode) + 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); - setRawTransaction(rawTransaction); + setProposedTransaction(proposedTransaction); setExpiration(expiration); setOwnerNode(ownerNode); } @@ -195,13 +195,13 @@ class TransactionProposalBuilder : public LedgerEntryBuilderBaseat(sfPreviousTxnID) = previousTxnIDValue; sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; sle->at(sfOwner) = ownerValue; - sle->setFieldObject(sfRawTransaction, rawTransactionValue); + sle->setFieldObject(sfProposedTransaction, proposedTransactionValue); sle->at(sfExpiration) = expirationValue; sle->at(sfOwnerNode) = ownerNodeValue; @@ -151,13 +151,13 @@ TEST(TransactionProposalTests, BuilderFromSleRoundTrip) } { - auto const& expected = rawTransactionValue; + auto const& expected = proposedTransactionValue; - auto const fromSle = entryFromSle.getRawTransaction(); - auto const fromBuilder = entryFromBuilder.getRawTransaction(); + auto const fromSle = entryFromSle.getProposedTransaction(); + auto const fromBuilder = entryFromBuilder.getProposedTransaction(); - expectEqualField(expected, fromSle, "sfRawTransaction"); - expectEqualField(expected, fromBuilder, "sfRawTransaction"); + expectEqualField(expected, fromSle, "sfProposedTransaction"); + expectEqualField(expected, fromBuilder, "sfProposedTransaction"); } { From 3f564efa37258658a87be0dda555c19ec3a39559 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Mon, 27 Jul 2026 11:59:18 -0400 Subject: [PATCH 04/21] comment --- include/xrpl/protocol/detail/ledger_entries.macro | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 516ec47a32e..88bd465935f 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -636,12 +636,13 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ {sfSponseeNode, SoeRequired}, })) -/** A ledger object holding a pending multi-signature proposal. +/** A ledger object holding a pending transaction proposal. - The proposed transaction is stored unsigned in ProposedTransaction; - collected signatures accumulate inside its Signers field. Once the - collected weight meets the target account's quorum, the stored transaction - is a fully signed transaction that anyone may copy and submit. + 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 */ From 85ff4b244608ac58a3007fc90088ae0c1970a0ce Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Mon, 27 Jul 2026 17:30:08 -0400 Subject: [PATCH 05/21] Add TransactionProposalCreate transaction --- .../xrpl/protocol/detail/transactions.macro | 14 + .../transactions/TransactionProposalCreate.h | 155 +++++++++ .../tx/transactors/proposal/ProposalHelpers.h | 25 ++ .../proposal/TransactionProposalCreate.h | 51 +++ .../proposal/TransactionProposalCreate.cpp | 312 ++++++++++++++++++ .../app/TransactionProposalCreate_test.cpp | 202 ++++++++++++ .../TransactionProposalCreateTests.cpp | 162 +++++++++ 7 files changed, 921 insertions(+) create mode 100644 include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h create mode 100644 include/xrpl/tx/transactors/proposal/ProposalHelpers.h create mode 100644 include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h create mode 100644 src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp create mode 100644 src/test/app/TransactionProposalCreate_test.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..025ec77396e 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1194,6 +1194,20 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCount, SoeOptional}, })) +/** This transaction posts an unsigned transaction on-ledger as a + TransactionProposal, pending multi-signature collection. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 92, TransactionProposalCreate, + Delegation::NotDelegable, + featureCosign, + NoPriv, + ({ + {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/transactions/TransactionProposalCreate.h b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h new file mode 100644 index 00000000000..47eed43ee72 --- /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 (92) + * Delegable: Delegation::NotDelegable + * Amendment: featureCosign + * Privileges: 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/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h new file mode 100644 index 00000000000..52e70cc3ea4 --- /dev/null +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +#include + +namespace xrpl { + +/** + * Owner-reserve increments held by a proposal, as defined by the + * On-Chain Cosigner XLS section 4.4. + */ +inline constexpr std::uint32_t ordinaryProposalOwnerCount = 5; +inline constexpr std::uint32_t batchProposalOwnerCount = 10; + +inline std::uint32_t +proposalOwnerCount(STObject const& proposedTx) +{ + return proposedTx.getFieldU16(sfTransactionType) == ttBATCH ? batchProposalOwnerCount + : ordinaryProposalOwnerCount; +} + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h new file mode 100644 index 00000000000..ea7eb162a0c --- /dev/null +++ b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h @@ -0,0 +1,51 @@ +#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; + +private: + // Invariant state: the proposal entry this transaction created, and any + // proposal entries it touched in another way (there must be none). + std::shared_ptr createdProposal_; + std::size_t createdProposals_ = 0; + std::size_t otherProposalTouches_ = 0; +}; + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp new file mode 100644 index 00000000000..72626fb14d8 --- /dev/null +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -0,0 +1,312 @@ +#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 raw = ctx.tx.getFieldObject(sfProposedTransaction); + + if (!raw.isFieldPresent(sfTransactionType) || !raw.isFieldPresent(sfAccount)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "lacks TransactionType or Account."; + 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. + switch (raw.getFieldU16(sfTransactionType)) + { + // The Sign/Cancel cases are added with their transaction PRs; those + // tt values do not exist yet. + case ttTRANSACTION_PROPOSAL_CREATE: + JLOG(ctx.j.debug()) << "TransactionProposalCreate: nested proposal."; + return temINVALID; + default: + break; + } + + if (isPseudoTx(raw)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is a " + "pseudo-transaction."; + return temINVALID; + } + + if (raw.isFieldPresent(sfFlags) && ((raw.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "carries tfInnerBatchTxn."; + return temINVALID; + } + + // The proposed transaction is stored in its unsigned canonical form; the + // ledger populates its signature fields as contributions arrive. + if (raw.isFieldPresent(sfTxnSignature) || raw.isFieldPresent(sfSigners) || + raw.isFieldPresent(sfBatchSigners) || raw.isFieldPresent(sfCounterpartySignature) || + raw.isFieldPresent(sfSponsorSignature)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "carries signature fields."; + return temBAD_SIGNER; + } + + if (!raw.isFieldPresent(sfSigningPubKey) || !raw.getFieldVL(sfSigningPubKey).empty()) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "SigningPubKey must be present and empty."; + return temBAD_SIGNER; + } + + // The proposed transaction's fee is charged to the target account when + // the completed transaction is submitted, so it must be fixed now. + if (!raw.isFieldPresent(sfFee) || !raw.isFieldPresent(sfSequence)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "lacks Fee or Sequence."; + return temMALFORMED; + } + + // The proposed transaction must be ticket-based: it must carry a + // TicketSequence and must not use a live Sequence (Sequence must be 0). A + // ticket decouples the proposal from the target account's live sequence, + // so unrelated target-account activity cannot invalidate it while + // signatures are collected (spec §4.2.1). + if (!raw.isFieldPresent(sfTicketSequence) || raw.getFieldU32(sfSequence) != 0) + return temSEQ_AND_TICKET; + + // The proposed transaction must pass its own static checks under the + // current rules, so no statically-dead proposal can be stored. TapDryRun + // accepts the unsigned canonical form without a signature check. + try + { + STTx const stx{ctx.tx.getFieldObject(sfProposedTransaction)}; + auto const inner = xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun, ctx.j); + if (!isTesSuccess(inner.ter)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "failed preflight: " + << transHuman(inner.ter); + // Surface the proposed transaction type's own preflight code + // rather than collapsing it to a generic error (spec §5.3.1). + return inner.ter; + } + } + catch (std::exception const& e) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is " + "malformed: " + << e.what(); + 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 raw = ctx.tx.getFieldObject(sfProposedTransaction); + + if (raw.isFieldPresent(sfLastLedgerSequence) && + raw.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq()) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "LastLedgerSequence has passed."; + return tecEXPIRED; + } + + AccountID const target = raw.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; + + std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); + + 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 raw = ctx_.tx.getFieldObject(sfProposedTransaction); + std::uint32_t const ownerCount = proposalOwnerCount(raw); + + // 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 = raw.getAccountID(sfAccount); + std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); + + Keylet const proposalKeylet = keylet::txProposal(target, ticketSequence); + auto sleProposal = std::make_shared(proposalKeylet); + sleProposal->setAccountID(sfOwner, accountID_); + sleProposal->setFieldObject(sfProposedTransaction, raw); + 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 isDelete, + SLE::const_ref before, + SLE::const_ref after) +{ + auto const& entry = after ? after : before; + if (!entry || entry->getType() != ltTRANSACTION_PROPOSAL) + return; + + if (!isDelete && !before && after) + { + ++createdProposals_; + createdProposal_ = after; + } + else + { + ++otherProposalTouches_; + } +} + +bool +TransactionProposalCreate::finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount, + ReadView const&, + beast::Journal const& j) +{ + if (!isTesSuccess(result)) + { + // A failed create claims a fee and nothing else. + if (createdProposals_ != 0 || otherProposalTouches_ != 0) + { + JLOG(j.fatal()) << "Invariant failed: failed TransactionProposalCreate " + "touched a proposal."; // LCOV_EXCL_LINE + return false; // LCOV_EXCL_LINE + } + return true; + } + + if (createdProposals_ != 1 || otherProposalTouches_ != 0 || !createdProposal_) + { + JLOG(j.fatal()) << "Invariant failed: TransactionProposalCreate must " + "create exactly one proposal."; // LCOV_EXCL_LINE + return false; // LCOV_EXCL_LINE + } + + auto const& sle = *createdProposal_; + if (sle[sfOwner] != tx[sfAccount] || sle[sfExpiration] != tx[sfExpiration] || + sle[sfExpiration] == 0) + { + JLOG(j.fatal()) << "Invariant failed: created proposal owner or " + "expiration mismatch."; // LCOV_EXCL_LINE + return false; // LCOV_EXCL_LINE + } + + // The stored transaction must be in unsigned canonical form; signatures + // may only ever arrive through TransactionProposalSign. + auto const raw = sle.getFieldObject(sfProposedTransaction); + if (raw.isFieldPresent(sfTxnSignature) || raw.isFieldPresent(sfSigners) || + !raw.isFieldPresent(sfSigningPubKey) || !raw.getFieldVL(sfSigningPubKey).empty()) + { + JLOG(j.fatal()) << "Invariant failed: created proposal is not in " + "unsigned canonical form."; // LCOV_EXCL_LINE + return false; // LCOV_EXCL_LINE + } + + // The entry must live under the key its components hash to, or lookups + // and duplicate detection fall apart. + std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); + if (sle.key() != keylet::txProposal(raw.getAccountID(sfAccount), ticketSequence).key) + { + JLOG(j.fatal()) << "Invariant failed: proposal stored under the " + "wrong key."; // LCOV_EXCL_LINE + return false; // LCOV_EXCL_LINE + } + + return true; +} + +} // namespace xrpl diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp new file mode 100644 index 00000000000..75e4e3266d8 --- /dev/null +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -0,0 +1,202 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +struct TransactionProposalCreate_test : public beast::unit_test::Suite +{ + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + void + testCreate(FeatureBitset features) + { + testcase("create proposal object"); + + 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"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // 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 proposedTx = pay(target, bob, XRP(1)); + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; + proposedTx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + proposedTx[jss::SigningPubKey] = ""; + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), targetTicketSeq)); + BEAST_EXPECT(sle); + if (!sle) + return; + + BEAST_EXPECT(sle->getAccountID(sfOwner) == alice.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 five owner increments against the proposer. + // The target only owns the Ticket used by the proposed transaction. + BEAST_EXPECT(ownerCount(env, alice) == 5); + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // The proposed transaction must be storable in unsigned canonical form and + // 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. + 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 alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::string const feeDrops = std::to_string(env.current()->fees().base.drops()); + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + // A payload that is accepted as-is; every case starts from this. + auto payload = [&]() { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = targetTicketSeq; + tx[jss::Fee] = feeDrops; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + auto reject = [&](json::Value const& proposedTx, TER expected) { + env(proposalCreate(alice, proposedTx, expiration), Ter(expected)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + }; + + // Signatures may only ever arrive through TransactionProposalSign. + { + json::Value tx = payload(); + tx[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + auto& signer = tx[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; + signer[jss::Account] = bob.human(); + signer[jss::SigningPubKey] = strHex(bob.pk().slice()); + signer[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + + // SigningPubKey must be present and empty: absent is not the same as + // empty, and a set key means the payload was signed for single-signing. + { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = targetTicketSeq; + tx[jss::Fee] = feeDrops; + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[jss::SigningPubKey] = strHex(target.pk().slice()); + reject(tx, temBAD_SIGNER); + } + + // A pseudo-transaction is never submittable by an account. + { + json::Value tx = payload(); + tx[jss::TransactionType] = jss::EnableAmendment; + reject(tx, temINVALID); + } + + // An inner batch transaction bypasses the ordinary signature checks. + { + json::Value tx = payload(); + tx[jss::Flags] = tfInnerBatchTxn; + reject(tx, temINVALID); + } + + // Proposals do not nest. + { + json::Value tx = payload(); + tx[jss::TransactionType] = "TransactionProposalCreate"; + reject(tx, temINVALID); + } + } + + void + run() override + { + using namespace jtx; + testCreate(testableAmendments()); + testRejectedPayload(testableAmendments()); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalCreate, app, xrpl); + +} // namespace xrpl::test 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 00000000000..58d6b2d0e7a --- /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); +} + + +} From b027258e5254515b162e90cf0b320b9355e51a94 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 12:12:55 -0400 Subject: [PATCH 06/21] naming --- .../proposal/TransactionProposalCreate.cpp | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index 72626fb14d8..a5ef1db66e3 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -38,9 +38,9 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) return temBAD_EXPIRATION; } - STObject const raw = ctx.tx.getFieldObject(sfProposedTransaction); + STObject const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); - if (!raw.isFieldPresent(sfTransactionType) || !raw.isFieldPresent(sfAccount)) + if (!proposedTx.isFieldPresent(sfTransactionType) || !proposedTx.isFieldPresent(sfAccount)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "lacks TransactionType or Account."; @@ -50,7 +50,7 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction must be independently submittable through the // ordinary multi-sign path: no nested proposals, no pseudo-transactions, // no batch inner transactions. - switch (raw.getFieldU16(sfTransactionType)) + switch (proposedTx.getFieldU16(sfTransactionType)) { // The Sign/Cancel cases are added with their transaction PRs; those // tt values do not exist yet. @@ -61,14 +61,15 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) break; } - if (isPseudoTx(raw)) + if (isPseudoTx(proposedTx)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is a " "pseudo-transaction."; return temINVALID; } - if (raw.isFieldPresent(sfFlags) && ((raw.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)) + if (proposedTx.isFieldPresent(sfFlags) && + ((proposedTx.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "carries tfInnerBatchTxn."; @@ -77,16 +78,18 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction is stored in its unsigned canonical form; the // ledger populates its signature fields as contributions arrive. - if (raw.isFieldPresent(sfTxnSignature) || raw.isFieldPresent(sfSigners) || - raw.isFieldPresent(sfBatchSigners) || raw.isFieldPresent(sfCounterpartySignature) || - raw.isFieldPresent(sfSponsorSignature)) + if (proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) || + proposedTx.isFieldPresent(sfBatchSigners) || + proposedTx.isFieldPresent(sfCounterpartySignature) || + proposedTx.isFieldPresent(sfSponsorSignature)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "carries signature fields."; return temBAD_SIGNER; } - if (!raw.isFieldPresent(sfSigningPubKey) || !raw.getFieldVL(sfSigningPubKey).empty()) + if (!proposedTx.isFieldPresent(sfSigningPubKey) || + !proposedTx.getFieldVL(sfSigningPubKey).empty()) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "SigningPubKey must be present and empty."; @@ -95,7 +98,7 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction's fee is charged to the target account when // the completed transaction is submitted, so it must be fixed now. - if (!raw.isFieldPresent(sfFee) || !raw.isFieldPresent(sfSequence)) + if (!proposedTx.isFieldPresent(sfFee) || !proposedTx.isFieldPresent(sfSequence)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "lacks Fee or Sequence."; @@ -107,7 +110,7 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // ticket decouples the proposal from the target account's live sequence, // so unrelated target-account activity cannot invalidate it while // signatures are collected (spec §4.2.1). - if (!raw.isFieldPresent(sfTicketSequence) || raw.getFieldU32(sfSequence) != 0) + if (!proposedTx.isFieldPresent(sfTicketSequence) || proposedTx.getFieldU32(sfSequence) != 0) return temSEQ_AND_TICKET; // The proposed transaction must pass its own static checks under the @@ -115,7 +118,7 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // accepts the unsigned canonical form without a signature check. try { - STTx const stx{ctx.tx.getFieldObject(sfProposedTransaction)}; + STTx const stx{STObject{proposedTx}}; auto const inner = xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun, ctx.j); if (!isTesSuccess(inner.ter)) { @@ -147,17 +150,17 @@ TransactionProposalCreate::preclaim(PreclaimContext const& ctx) return tecEXPIRED; } - auto const raw = ctx.tx.getFieldObject(sfProposedTransaction); + auto const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); - if (raw.isFieldPresent(sfLastLedgerSequence) && - raw.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq()) + 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 = raw.getAccountID(sfAccount); + AccountID const target = proposedTx.getAccountID(sfAccount); auto const sleTarget = ctx.view.read(keylet::account(target)); if (!sleTarget) { @@ -170,7 +173,7 @@ TransactionProposalCreate::preclaim(PreclaimContext const& ctx) if (isPseudoAccount(sleTarget)) return tecNO_PERMISSION; - std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); if (ctx.view.exists(keylet::txProposal(target, ticketSequence))) { @@ -188,8 +191,8 @@ TransactionProposalCreate::doApply() if (!sle) return tefINTERNAL; // LCOV_EXCL_LINE - auto const raw = ctx_.tx.getFieldObject(sfProposedTransaction); - std::uint32_t const ownerCount = proposalOwnerCount(raw); + auto const proposedTx = ctx_.tx.getFieldObject(sfProposedTransaction); + std::uint32_t const ownerCount = 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 @@ -203,13 +206,13 @@ TransactionProposalCreate::doApply() !isTesSuccess(ret)) return ret; - AccountID const target = raw.getAccountID(sfAccount); - std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); + 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, raw); + sleProposal->setFieldObject(sfProposedTransaction, proposedTx); sleProposal->setFieldU32(sfExpiration, ctx_.tx[sfExpiration]); view().insert(sleProposal); @@ -287,9 +290,10 @@ TransactionProposalCreate::finalizeInvariants( // The stored transaction must be in unsigned canonical form; signatures // may only ever arrive through TransactionProposalSign. - auto const raw = sle.getFieldObject(sfProposedTransaction); - if (raw.isFieldPresent(sfTxnSignature) || raw.isFieldPresent(sfSigners) || - !raw.isFieldPresent(sfSigningPubKey) || !raw.getFieldVL(sfSigningPubKey).empty()) + auto const proposedTx = sle.getFieldObject(sfProposedTransaction); + if (proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) || + !proposedTx.isFieldPresent(sfSigningPubKey) || + !proposedTx.getFieldVL(sfSigningPubKey).empty()) { JLOG(j.fatal()) << "Invariant failed: created proposal is not in " "unsigned canonical form."; // LCOV_EXCL_LINE @@ -298,8 +302,8 @@ TransactionProposalCreate::finalizeInvariants( // The entry must live under the key its components hash to, or lookups // and duplicate detection fall apart. - std::uint32_t const ticketSequence = raw.getFieldU32(sfTicketSequence); - if (sle.key() != keylet::txProposal(raw.getAccountID(sfAccount), ticketSequence).key) + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); + if (sle.key() != keylet::txProposal(proposedTx.getAccountID(sfAccount), ticketSequence).key) { JLOG(j.fatal()) << "Invariant failed: proposal stored under the " "wrong key."; // LCOV_EXCL_LINE From 52fe7a69cd44b09616bc723753c46797a25fc1a1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 12:38:48 -0400 Subject: [PATCH 07/21] clang-tidy --- .../xrpl/tx/transactors/proposal/TransactionProposalCreate.h | 3 +++ .../tx/transactors/proposal/TransactionProposalCreate.cpp | 2 +- src/test/app/TransactionProposalCreate_test.cpp | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h index ea7eb162a0c..3bce6397a1e 100644 --- a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h +++ b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h @@ -9,6 +9,9 @@ #include #include +#include +#include + namespace xrpl { class TransactionProposalCreate : public Transactor diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index a5ef1db66e3..da9fdc6cbec 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -8,9 +8,9 @@ #include #include #include -#include #include #include +#include #include #include #include diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index 75e4e3266d8..4eaf7a38dd1 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include From edeaf5471921eb4023d60514065b162d6783645d Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 14:10:49 -0400 Subject: [PATCH 08/21] fix --- .../tx/transactors/proposal/ProposalHelpers.h | 48 ++++++++++++++++--- .../proposal/TransactionProposalCreate.cpp | 12 ++--- .../app/TransactionProposalCreate_test.cpp | 5 +- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h index 52e70cc3ea4..83fc51d8a20 100644 --- a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -9,17 +9,53 @@ namespace xrpl { /** - * Owner-reserve increments held by a proposal, as defined by the - * On-Chain Cosigner XLS section 4.4. + * 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 constexpr std::uint32_t ordinaryProposalOwnerCount = 5; -inline constexpr std::uint32_t batchProposalOwnerCount = 10; +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(); +} +/** + * 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 ? batchProposalOwnerCount - : ordinaryProposalOwnerCount; + return proposedTx.getFieldU16(sfTransactionType) == ttBATCH ? kBatchProposalOwnerCount + : kProposalOwnerCount; } } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index da9fdc6cbec..bad575649a3 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -78,18 +78,14 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction is stored in its unsigned canonical form; the // ledger populates its signature fields as contributions arrive. - if (proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) || - proposedTx.isFieldPresent(sfBatchSigners) || - proposedTx.isFieldPresent(sfCounterpartySignature) || - proposedTx.isFieldPresent(sfSponsorSignature)) + if (hasSignatureField(proposedTx)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "carries signature fields."; return temBAD_SIGNER; } - if (!proposedTx.isFieldPresent(sfSigningPubKey) || - !proposedTx.getFieldVL(sfSigningPubKey).empty()) + if (!hasEmptySigningPubKey(proposedTx)) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "SigningPubKey must be present and empty."; @@ -291,9 +287,7 @@ TransactionProposalCreate::finalizeInvariants( // The stored transaction must be in unsigned canonical form; signatures // may only ever arrive through TransactionProposalSign. auto const proposedTx = sle.getFieldObject(sfProposedTransaction); - if (proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) || - !proposedTx.isFieldPresent(sfSigningPubKey) || - !proposedTx.getFieldVL(sfSigningPubKey).empty()) + if (hasSignatureField(proposedTx) || !hasEmptySigningPubKey(proposedTx)) { JLOG(j.fatal()) << "Invariant failed: created proposal is not in " "unsigned canonical form."; // LCOV_EXCL_LINE diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index 4eaf7a38dd1..c48aece2682 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -88,9 +89,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq); BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty()); - // The proposal reserves five owner increments against the proposer. + // The proposal reserves several owner increments against the proposer. // The target only owns the Ticket used by the proposed transaction. - BEAST_EXPECT(ownerCount(env, alice) == 5); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); BEAST_EXPECT(ownerCount(env, target) == 1); } From 9f71f722002e2721fbaeac0454e7538b5b759bf6 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 14:19:57 -0400 Subject: [PATCH 09/21] style --- .../xrpl/tx/transactors/proposal/ProposalHelpers.h | 13 +++++++++++++ .../proposal/TransactionProposalCreate.cpp | 12 +++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h index 83fc51d8a20..c899e154821 100644 --- a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -8,6 +8,19 @@ namespace xrpl { +/** + * 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 carries any signature field. * diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index bad575649a3..634227db669 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -50,15 +49,10 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction must be independently submittable through the // ordinary multi-sign path: no nested proposals, no pseudo-transactions, // no batch inner transactions. - switch (proposedTx.getFieldU16(sfTransactionType)) + if (isProposalTx(proposedTx)) { - // The Sign/Cancel cases are added with their transaction PRs; those - // tt values do not exist yet. - case ttTRANSACTION_PROPOSAL_CREATE: - JLOG(ctx.j.debug()) << "TransactionProposalCreate: nested proposal."; - return temINVALID; - default: - break; + JLOG(ctx.j.debug()) << "TransactionProposalCreate: nested proposal."; + return temINVALID; } if (isPseudoTx(proposedTx)) From 40118691d62c6f53f630f99526937dbaee92ee8b Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 14:37:50 -0400 Subject: [PATCH 10/21] fix --- .../proposal/TransactionProposalCreate.cpp | 15 +- .../app/TransactionProposalCreate_test.cpp | 254 ++++++++++++++++-- 2 files changed, 246 insertions(+), 23 deletions(-) diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index 634227db669..a2521c993f0 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -96,10 +96,11 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) } // The proposed transaction must be ticket-based: it must carry a - // TicketSequence and must not use a live Sequence (Sequence must be 0). A - // ticket decouples the proposal from the target account's live sequence, - // so unrelated target-account activity cannot invalidate it while - // signatures are collected (spec §4.2.1). + // 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 (spec §4.2.1). if (!proposedTx.isFieldPresent(sfTicketSequence) || proposedTx.getFieldU32(sfSequence) != 0) return temSEQ_AND_TICKET; @@ -142,8 +143,12 @@ TransactionProposalCreate::preclaim(PreclaimContext const& ctx) 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 (spec §4.5). if (proposedTx.isFieldPresent(sfLastLedgerSequence) && - proposedTx.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq()) + proposedTx.getFieldU32(sfLastLedgerSequence) < ctx.view.seq()) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "LastLedgerSequence has passed."; diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index c48aece2682..1a9743c04d0 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,23 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite return jv; } + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env const& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + void testCreate(FeatureBitset features) { @@ -64,11 +82,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // 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 proposedTx = pay(target, bob, XRP(1)); - proposedTx[jss::Sequence] = 0; - proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; - proposedTx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); - proposedTx[jss::SigningPubKey] = ""; + json::Value const proposedTx = unsignedPayload(env, target, bob, targetTicketSeq); std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); @@ -118,18 +132,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(ticket::create(target, 1)); env.close(); - std::string const feeDrops = std::to_string(env.current()->fees().base.drops()); std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); // A payload that is accepted as-is; every case starts from this. - auto payload = [&]() { - json::Value tx = pay(target, bob, XRP(1)); - tx[jss::Sequence] = 0; - tx[sfTicketSequence.getJsonName()] = targetTicketSeq; - tx[jss::Fee] = feeDrops; - tx[jss::SigningPubKey] = ""; - return tx; - }; + auto payload = [&]() { return unsignedPayload(env, target, bob, targetTicketSeq); }; auto reject = [&](json::Value const& proposedTx, TER expected) { env(proposalCreate(alice, proposedTx, expiration), Ter(expected)); @@ -156,10 +162,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // SigningPubKey must be present and empty: absent is not the same as // empty, and a set key means the payload was signed for single-signing. { - json::Value tx = pay(target, bob, XRP(1)); - tx[jss::Sequence] = 0; - tx[sfTicketSequence.getJsonName()] = targetTicketSeq; - tx[jss::Fee] = feeDrops; + json::Value tx = payload(); + tx.removeMember(jss::SigningPubKey); reject(tx, temBAD_SIGNER); } { @@ -190,12 +194,226 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite } } + // 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 alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, targetTicketSeq), expiration), + Ter(temDISABLED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // 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 alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const carol{"carol"}; // never funded + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const firstTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 3)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + // The proposal's own expiration has already passed. + { + std::uint32_t const past = env.now().time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, firstTicketSeq), past), + Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // 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 = unsignedPayload(env, target, bob, firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1; + env(proposalCreate(alice, tx, expiration), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // The last ledger in which the proposed transaction may still be + // submitted is the current one, so the proposal is still alive. + { + json::Value tx = unsignedPayload(env, target, bob, firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); + env(proposalCreate(alice, tx, expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // The target and ticket already carry a proposal. + { + env(proposalCreate( + alice, unsignedPayload(env, target, bob, firstTicketSeq), expiration), + Ter(tecDUPLICATE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // A different ticket of the same target is a different proposal. + { + env(proposalCreate( + alice, unsignedPayload(env, target, bob, firstTicketSeq + 1), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), firstTicketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + } + + // The target account does not exist, so it can never sign. + { + env(proposalCreate(alice, unsignedPayload(env, carol, bob, 1), expiration), + Ter(tecNO_TARGET)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(carol.id(), 1))); + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + } + } + + // 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(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // Fund alice just short of the reserve the proposal requires. + env.fund(env.current()->fees().accountReserve(kProposalOwnerCount, 1) - drops(1), alice); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + json::Value const proposedTx = unsignedPayload(env, target, bob, targetTicketSeq); + + env(proposalCreate(alice, proposedTx, expiration), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + env(pay(bob, alice, XRP(10))); + env.close(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // 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 alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](std::uint32_t seq) { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 0, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = inner(env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(env.seq(target) + 1); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + } + void run() override { using namespace jtx; + testDisabled(testableAmendments()); testCreate(testableAmendments()); testRejectedPayload(testableAmendments()); + testPreclaim(testableAmendments()); + testReserve(testableAmendments()); + testBatchReserve(testableAmendments()); } }; From e130009b48bee294d67f9d9f68fc78418ee328b2 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 15:48:34 -0400 Subject: [PATCH 11/21] fix --- include/xrpl/ledger/ApplyView.h | 8 ++- .../proposal/TransactionProposalCreate.cpp | 7 +- src/libxrpl/tx/transactors/system/Batch.cpp | 7 ++ .../app/TransactionProposalCreate_test.cpp | 67 +++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index 724d89b7c6a..1b2cd3d7e1f 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -43,7 +43,13 @@ 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 (spec §5.3.1.2). + TapProposal = 0x2000 }; constexpr ApplyFlags diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index a2521c993f0..aeda3e28f16 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -106,11 +106,14 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx) // The proposed transaction must pass its own static checks under the // current rules, so no statically-dead proposal can be stored. TapDryRun - // accepts the unsigned canonical form without a signature check. + // accepts the unsigned canonical form without a signature check; + // TapProposal additionally skips signature-presence checks (e.g. Batch + // signer matching), which are deferred to submission time (spec §5.3.1.2). try { STTx const stx{STObject{proposedTx}}; - auto const inner = xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun, ctx.j); + 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 " diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index ccb113e07ba..290345ecb56 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -406,6 +406,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 (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 index 1a9743c04d0..d84982756f5 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -404,6 +404,72 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); } + // A multi-account Batch is the primary motivating case (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 (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 alice{"alice"}; + Account const target{"target"}; // outer account of the batch + Account const bob{"bob"}; // a distinct inner participant + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](Account const& from, Account const& to, std::uint32_t seq) { + json::Value tx = pay(from, to, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + // 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 proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 1, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = + inner(target, bob, env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = + inner(bob, target, env.seq(bob)); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), targetTicketSeq)); + BEAST_EXPECT(sle); + if (!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)); + BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + } + void run() override { @@ -414,6 +480,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite testPreclaim(testableAmendments()); testReserve(testableAmendments()); testBatchReserve(testableAmendments()); + testMultiAccountBatch(testableAmendments()); } }; From 6435e4f604b013933025bc4abaf934e02d3d037e Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 17:02:23 -0400 Subject: [PATCH 12/21] test --- .../app/TransactionProposalCreate_test.cpp | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index d84982756f5..4492b3ac0ba 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -192,6 +194,57 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite tx[jss::TransactionType] = "TransactionProposalCreate"; reject(tx, temINVALID); } + + // The remaining signature containers are just as forbidden as a bare + // TxnSignature or Signers array. + { + json::Value tx = payload(); + auto& bs = tx[sfBatchSigners.getJsonName()][0u][sfBatchSigner.getJsonName()]; + bs[jss::Account] = bob.human(); + bs[jss::SigningPubKey] = strHex(bob.pk().slice()); + bs[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[sfCounterpartySignature.getJsonName()][jss::SigningPubKey] = + strHex(bob.pk().slice()); + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[sfSponsorSignature.getJsonName()][jss::SigningPubKey] = strHex(bob.pk().slice()); + reject(tx, temBAD_SIGNER); + } + + // 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); + } + + // A payload that fails its own transaction type's preflight surfaces + // that type's own code, not a generic error (spec §5.3.1.2). + { + json::Value tx = payload(); + tx[jss::Amount] = "0"; + reject(tx, temBAD_AMOUNT); + } + + // Expiration must be present and non-zero. + { + env(proposalCreate(alice, payload(), 0), Ter(temBAD_EXPIRATION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } } // Nothing about the transaction is available before the amendment is @@ -470,6 +523,50 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); } + // 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 (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"}; + 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 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 proposedTx = pay(alice, bob, XRP(1)); + proposedTx[jss::Account] = toBase58(amm.ammAccount()); + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = 1; + proposedTx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + proposedTx[jss::SigningPubKey] = ""; + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(proposer, proposedTx, expiration), Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(amm.ammAccount(), 1))); + BEAST_EXPECT(ownerCount(env, proposer) == 0); + } + void run() override { @@ -481,6 +578,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite testReserve(testableAmendments()); testBatchReserve(testableAmendments()); testMultiAccountBatch(testableAmendments()); + testPseudoTarget(testableAmendments()); } }; From ac4716a87d0953e2a4777d52cc1d37087c3d74ef Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 28 Jul 2026 17:13:17 -0400 Subject: [PATCH 13/21] clang-tidy --- src/test/app/TransactionProposalCreate_test.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index 4492b3ac0ba..05c24e3c690 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -540,6 +540,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite 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(); @@ -548,7 +549,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(pay(gw, alice, USD(10'000))); env.close(); - AMM amm(env, alice, XRP(1'000), USD(1'000), Ter(tesSUCCESS)); + 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. From 482c33831d6570288e2b61c2079074ea57523fab Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Thu, 30 Jul 2026 12:50:03 -0400 Subject: [PATCH 14/21] fix --- .../proposal/TransactionProposalCreate.h | 10 --- .../proposal/TransactionProposalCreate.cpp | 77 +++---------------- 2 files changed, 9 insertions(+), 78 deletions(-) diff --git a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h index 3bce6397a1e..a8c36b5611b 100644 --- a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h +++ b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h @@ -9,9 +9,6 @@ #include #include -#include -#include - namespace xrpl { class TransactionProposalCreate : public Transactor @@ -42,13 +39,6 @@ class TransactionProposalCreate : public Transactor XRPAmount fee, ReadView const& view, beast::Journal const& j) override; - -private: - // Invariant state: the proposal entry this transaction created, and any - // proposal entries it touched in another way (there must be none). - std::shared_ptr createdProposal_; - std::size_t createdProposals_ = 0; - std::size_t otherProposalTouches_ = 0; }; } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index aeda3e28f16..7a49c11bb97 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -230,82 +230,23 @@ TransactionProposalCreate::doApply() } void -TransactionProposalCreate::visitInvariantEntry( - bool isDelete, - SLE::const_ref before, - SLE::const_ref after) +TransactionProposalCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) { - auto const& entry = after ? after : before; - if (!entry || entry->getType() != ltTRANSACTION_PROPOSAL) - return; - - if (!isDelete && !before && after) - { - ++createdProposals_; - createdProposal_ = after; - } - else - { - ++otherProposalTouches_; - } + // 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& tx, - TER result, + STTx const&, + TER, XRPAmount, ReadView const&, - beast::Journal const& j) + beast::Journal const&) { - if (!isTesSuccess(result)) - { - // A failed create claims a fee and nothing else. - if (createdProposals_ != 0 || otherProposalTouches_ != 0) - { - JLOG(j.fatal()) << "Invariant failed: failed TransactionProposalCreate " - "touched a proposal."; // LCOV_EXCL_LINE - return false; // LCOV_EXCL_LINE - } - return true; - } - - if (createdProposals_ != 1 || otherProposalTouches_ != 0 || !createdProposal_) - { - JLOG(j.fatal()) << "Invariant failed: TransactionProposalCreate must " - "create exactly one proposal."; // LCOV_EXCL_LINE - return false; // LCOV_EXCL_LINE - } - - auto const& sle = *createdProposal_; - if (sle[sfOwner] != tx[sfAccount] || sle[sfExpiration] != tx[sfExpiration] || - sle[sfExpiration] == 0) - { - JLOG(j.fatal()) << "Invariant failed: created proposal owner or " - "expiration mismatch."; // LCOV_EXCL_LINE - return false; // LCOV_EXCL_LINE - } - - // The stored transaction must be in unsigned canonical form; signatures - // may only ever arrive through TransactionProposalSign. - auto const proposedTx = sle.getFieldObject(sfProposedTransaction); - if (hasSignatureField(proposedTx) || !hasEmptySigningPubKey(proposedTx)) - { - JLOG(j.fatal()) << "Invariant failed: created proposal is not in " - "unsigned canonical form."; // LCOV_EXCL_LINE - return false; // LCOV_EXCL_LINE - } - - // The entry must live under the key its components hash to, or lookups - // and duplicate detection fall apart. - std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); - if (sle.key() != keylet::txProposal(proposedTx.getAccountID(sfAccount), ticketSequence).key) - { - JLOG(j.fatal()) << "Invariant failed: proposal stored under the " - "wrong key."; // LCOV_EXCL_LINE - return false; // LCOV_EXCL_LINE - } - + // No transaction-specific invariants yet (future work). return true; } From 93410a51672c945000313e13b9ca3d358b1697d3 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Thu, 30 Jul 2026 14:05:08 -0400 Subject: [PATCH 15/21] fix --- .../tx/transactors/proposal/TransactionProposalCreate.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index 7a49c11bb97..9da50c201ab 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include From 4bbb118ab8e4d72309788b3182ad5c0afec20ca9 Mon Sep 17 00:00:00 2001 From: Zhiyuan Wang <1830604455@qq.com> Date: Tue, 4 Aug 2026 14:14:34 -0400 Subject: [PATCH 16/21] fix --- .../proposal/TransactionProposalCreate.cpp | 2 +- .../app/TransactionProposalCreate_test.cpp | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp index 9da50c201ab..e776e93a915 100644 --- a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -150,7 +150,7 @@ TransactionProposalCreate::preclaim(PreclaimContext const& ctx) // ordinary path uses for tefMAX_LEDGER: the last ledger in which the // proposed transaction may still be submitted (spec §4.5). if (proposedTx.isFieldPresent(sfLastLedgerSequence) && - proposedTx.getFieldU32(sfLastLedgerSequence) < ctx.view.seq()) + proposedTx.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq()) { JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " "LastLedgerSequence has passed."; diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index 05c24e3c690..ea1939a45fd 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -325,12 +325,24 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 0); } - // The last ledger in which the proposed transaction may still be - // submitted is the current one, so the proposal is still alive. + // 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 + // (spec §5.3.2.2). { json::Value tx = unsignedPayload(env, target, bob, firstTicketSeq); tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); - env(proposalCreate(alice, tx, expiration)); + env(proposalCreate(alice, tx, expiration), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // With no ledger bound on the proposed transaction, the proposal is + // created normally. + { + env(proposalCreate( + alice, unsignedPayload(env, target, bob, firstTicketSeq), expiration)); env.close(); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), firstTicketSeq))); BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); From c5d07f93bc81b5bdf24c540bf128fe24eb4d86a5 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Aug 2026 13:32:05 -0700 Subject: [PATCH 17/21] Auto-delete TransactionProposal when its TicketSequence is consumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per XLS-0103 §4.5, whenever the target account applies a transaction that consumes a Ticket, any TransactionProposal keyed to that ticket can never execute, so the ledger deletes it and releases the reserve it holds against its Owner. The cleanup lives in Transactor::ticketDelete, the single choke point for Ticket removal, so it covers the successful path, the tec claimed-fee path, and AccountDelete's ticket sweep alike, and is gated on the Cosign amendment. The deletion routine is a shared ProposalHelpers entry point so the future TransactionProposalCancel and TransactionProposalSign cleanup paths (XLS-0103 §6.4) reuse it. Co-Authored-By: Claude Fable 5 --- .../tx/transactors/proposal/ProposalHelpers.h | 28 ++ src/libxrpl/tx/Transactor.cpp | 18 + .../transactors/proposal/ProposalHelpers.cpp | 65 +++ .../TransactionProposalAutoDelete_test.cpp | 455 ++++++++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp create mode 100644 src/test/app/TransactionProposalAutoDelete_test.cpp diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h index c899e154821..05af5d9fc08 100644 --- a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -1,7 +1,11 @@ #pragma once +#include +#include #include +#include #include +#include #include #include @@ -71,4 +75,28 @@ proposalOwnerCount(STObject const& proposedTx) : kProposalOwnerCount; } +/** + * Delete a TransactionProposal ledger entry. + * + * Removes the entry from its Owner's directory, releases the reserve the + * proposal holds against the Owner, and erases the entry. Shared by every + * deletion path the spec defines (XLS-0103 §4.5): automatic cleanup when the + * proposed transaction's TicketSequence is consumed, and the + * TransactionProposalCancel / TransactionProposalSign cleanup paths once + * those transactions exist. + * + * A TransactionProposal cannot carry a reserve sponsor today (its type is + * not sponsorship-supported), so the release always lands on the Owner; it + * goes through decreaseOwnerCountForObject regardless, matching ticketDelete, + * so it would follow an sfSponsor recorded on the entry if the type ever + * becomes sponsorable. + * + * @param view The apply view for making changes + * @param sleProposal The TransactionProposal ledger entry to delete + * @param j Journal for logging + * @return tesSUCCESS, or tefBAD_LEDGER if the ledger contradicts the entry + */ +TER +deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j); + } // namespace xrpl diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 4b562692d75..a852ecf35c8 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include @@ -864,8 +865,25 @@ Transactor::ticketDelete( // Update the Ticket owner's reserve. decreaseOwnerCountForObject(view, sleAccount, sleTicket, 1, j); + std::uint32_t const ticketSeq{(*sleTicket)[sfTicketSequence]}; + // Remove Ticket from ledger. view.erase(sleTicket); + + // Once the Ticket is gone, a TransactionProposal keyed to it can never + // execute: its proposed transaction would fail with tefNO_TICKET. Clean + // up the stale proposal and release its Owner's reserve (XLS-0103 §4.5). + // This runs both when the proposal's own completed transaction consumes + // the ticket and when the account spends the ticket on something else. + if (view.rules().enabled(featureCosign)) + { + if (auto const sleProposal = view.peek(keylet::txProposal(account, ticketSeq))) + { + if (TER const ter = deleteProposal(view, sleProposal, j); !isTesSuccess(ter)) + return ter; // LCOV_EXCL_LINE + } + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp new file mode 100644 index 00000000000..33b9119ae0d --- /dev/null +++ b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp @@ -0,0 +1,65 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +TER +deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j) +{ + // view carries no null contract (a reference), but the two parameters are + // bound to each other: sleProposal must be a live entry of this same + // view, since the directory removal, owner-root peek, and erase below all + // mutate that view assuming they see the entry's state. + XRPL_ASSERT( + sleProposal && sleProposal->getType() == ltTRANSACTION_PROPOSAL && + view.exists(Keylet{ltTRANSACTION_PROPOSAL, sleProposal->key()}), + "xrpl::deleteProposal : valid proposal sle of this view"); + + AccountID const owner = sleProposal->getAccountID(sfOwner); + + std::uint64_t const page{(*sleProposal)[sfOwnerNode]}; + if (!view.dirRemove(keylet::ownerDir(owner), page, sleProposal->key(), true)) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "Unable to delete TransactionProposal from owner."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const sleOwner = view.peek(keylet::account(owner)); + if (!sleOwner) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "Could not find TransactionProposal owner account root."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + // Release the reserve against the Owner or, if the entry carries a + // reserve sponsor, against that sponsor. + decreaseOwnerCountForObject( + view, + sleOwner, + sleProposal, + proposalOwnerCount(sleProposal->getFieldObject(sfProposedTransaction)), + j); + + view.erase(sleProposal); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/test/app/TransactionProposalAutoDelete_test.cpp b/src/test/app/TransactionProposalAutoDelete_test.cpp new file mode 100644 index 00000000000..745b70fa0e4 --- /dev/null +++ b/src/test/app/TransactionProposalAutoDelete_test.cpp @@ -0,0 +1,455 @@ +#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 { + +// Automatic cleanup of a TransactionProposal when the proposed transaction's +// TicketSequence is consumed (XLS-0103 §4.5): any transaction of the target +// account that spends the ticket makes the proposal permanently unexecutable, +// so applying that transaction deletes the proposal and releases the reserve +// it holds against its Owner. +struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite +{ + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env const& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + + // Consuming the proposed transaction's ticket deletes the proposal and + // refunds the Owner's reserve, whether the ticket is spent on the + // proposal's own transaction or on something unrelated. Consuming a + // different ticket, or the target's live sequence, leaves it untouched. + void + testTicketSpendDeletesProposal(FeatureBitset features) + { + testcase("ticket spend deletes proposal"); + + 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 proposals are for + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 2)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq + 1), expiration)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + // Each proposal reserves several owner increments; the target owns + // only its two Tickets. + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, target) == 2); + + // A sequence-based transaction of the target consumes no ticket, so + // both proposals survive. + env(noop(target)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + + // The proposal's own transaction runs: the target submits the very + // payment the first proposal holds, spending its ticket. That is the + // completed-proposal case of XLS-0103 §6.5 — execution goes through + // the ordinary path and the consumed ticket auto-deletes the proposal. + env(pay(target, bob, XRP(1)), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, target) == 1); + + // The target spends the second ticket on something unrelated to the + // proposal. The proposal can then never execute, so it is deleted all + // the same. + env(noop(target), ticket::Use(ticketSeq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // The proposal is keyed by target account and ticket, so another account + // consuming its own ticket of the same numeric sequence must not touch it. + void + testOtherAccountsTicket(FeatureBitset features) + { + testcase("other account's ticket does not delete proposal"); + + 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), alice, target, bob); + env.close(); + + // target and bob were funded together, so creating one ticket each in + // the same ledger gives their tickets the same numeric sequence. + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + std::uint32_t const bobTicketSeq = env.seq(bob) + 1; + env(ticket::create(target, 1)); + env(ticket::create(bob, 1)); + env.close(); + BEAST_EXPECT(targetTicketSeq == bobTicketSeq); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, targetTicketSeq), expiration)); + env.close(); + + env(noop(bob), ticket::Use(bobTicketSeq)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // A ticket is consumed even when its transaction fails with a tec, and + // once consumed the proposal can never execute, so a claimed-fee failure + // cleans up the proposal exactly as a success does. + void + testTecResultStillDeletes(FeatureBitset features) + { + testcase("tec result still deletes proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + // The payment fails but claims a fee, which consumes the ticket. + env(pay(target, bob, XRP(1'000'000)), ticket::Use(ticketSeq), Ter(tecUNFUNDED_PAYMENT)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // A proposed Batch reserves more increments than an ordinary proposal; + // deletion must release exactly what creation reserved. + void + testBatchProposalReserveRefund(FeatureBitset features) + { + testcase("batch proposal refunds its larger 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](std::uint32_t seq) { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = ticketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 0, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = inner(env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(env.seq(target) + 1); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // An inner Batch transaction consumes its ticket through the same path as + // a standalone transaction, so it too cleans up a proposal keyed to it. + void + testInnerBatchTicketSpend(FeatureBitset features) + { + testcase("inner batch transaction ticket spend deletes proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + // The target submits a Batch whose second inner transaction spends + // the proposal's ticket. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfAllOrNothing), + batch::Inner(pay(target, bob, XRP(1)), seq + 1), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // Deleting the target account removes its Tickets, after which a proposal + // keyed to one of them can never execute. The proposal is owned by the + // proposer — never by the deleted account, for which it would have been a + // deletion blocker — so it is cleaned up and the proposer refunded. + void + testTargetAccountDeleted(FeatureBitset features) + { + testcase("deleting the target account deletes proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // Expire far enough out that the proposal is still live when the + // account becomes deletable. + std::uint32_t const expiration = (env.now() + 3600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + + incLgrSeqForAccDel(env, target); + env(acctdelete(target, bob), Fee(env.current()->fees().increment)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::account(target.id()))); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // The proposer may be the target itself. Then the ticket bookkeeping and + // the proposal's reserve release both land on the same account-root SLE + // within one ticketDelete call, so this pins the aliasing case. + void + testProposerIsTarget(FeatureBitset features) + { + testcase("proposer is the target account"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(alice) + 1; + env(ticket::create(alice, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, alice, bob, ticketSeq), expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(alice.id(), ticketSeq)); + BEAST_EXPECT(sle && sle->getAccountID(sfOwner) == alice.id()); + // One Ticket plus the proposal's increments, all against alice. + BEAST_EXPECT(ownerCount(env, alice) == 1 + kProposalOwnerCount); + + env(noop(alice), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(alice.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // A TransactionProposal blocks its Owner's account deletion (XLS-0103 + // §4.6). Beyond the spec requirement, this blocker is what guarantees the + // AccountDelete ticket sweep never deletes a proposal out of the very + // owner directory it is iterating: any proposal reached through a swept + // ticket is necessarily owned by an account other than the one being + // deleted. + void + testProposalBlocksOwnerAccountDelete(FeatureBitset features) + { + testcase("proposal blocks its owner's account deletion"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; // proposer, tries to delete itself + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 3600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + incLgrSeqForAccDel(env, alice); + env(acctdelete(alice, bob), Fee(env.current()->fees().increment), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + BEAST_EXPECT(env.le(keylet::account(alice.id()))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // No sponsored-proposal case: a TransactionProposal cannot carry a + // reserve sponsor today — ttTRANSACTION_PROPOSAL_CREATE is not in the v1 + // reserve-sponsorship allow-list (isReserveSponsorAllowed) and + // ltTRANSACTION_PROPOSAL is not transferable to a sponsor + // (isLedgerEntrySupportedBySponsorship). deleteProposal releases the + // reserve through decreaseOwnerCountForObject, so if those lists ever + // grow, deletion follows the sfSponsor recorded on the entry. + + void + run() override + { + using namespace jtx; + testTicketSpendDeletesProposal(testableAmendments()); + testOtherAccountsTicket(testableAmendments()); + testTecResultStillDeletes(testableAmendments()); + testBatchProposalReserveRefund(testableAmendments()); + testInnerBatchTicketSpend(testableAmendments()); + testTargetAccountDeleted(testableAmendments()); + testProposerIsTarget(testableAmendments()); + testProposalBlocksOwnerAccountDelete(testableAmendments()); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalAutoDelete, app, xrpl); + +} // namespace xrpl::test From be494aac1fe453dc4090e0dc51def4958d462eef Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Tue, 4 Aug 2026 15:28:53 -0700 Subject: [PATCH 18/21] test: Cover remaining ticket-consumption paths for proposal auto-delete Batch failure modes (discard, failed-inner, partial), delegated and fee-sponsored spends, ticket-funded TicketCreate, expired proposals, directory integrity, and multi-page owner directories. Co-Authored-By: Claude Fable 5 --- .../TransactionProposalAutoDelete_test.cpp | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) diff --git a/src/test/app/TransactionProposalAutoDelete_test.cpp b/src/test/app/TransactionProposalAutoDelete_test.cpp index 745b70fa0e4..f5fcd5094f4 100644 --- a/src/test/app/TransactionProposalAutoDelete_test.cpp +++ b/src/test/app/TransactionProposalAutoDelete_test.cpp @@ -4,9 +4,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -130,6 +133,14 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq + 1))); BEAST_EXPECT(ownerCount(env, alice) == 0); BEAST_EXPECT(ownerCount(env, target) == 0); + + // Directory integrity: deletion must have unlinked the proposals from + // alice's owner directory, not just erased the entries. A dangling + // directory entry would make this AccountDelete fail. + incLgrSeqForAccDel(env, alice); + env(acctdelete(alice, bob), Fee(env.current()->fees().increment)); + env.close(); + BEAST_EXPECT(!env.le(keylet::account(alice.id()))); } // The proposal is keyed by target account and ticket, so another account @@ -427,6 +438,347 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); } + // A tfAllOrNothing Batch whose inner transaction spends the proposal's + // ticket but whose sibling fails is discarded entirely: the ticket + // survives, so the proposal — still executable — must survive with it. + void + testBatchDiscardKeepsProposal(FeatureBitset features) + { + testcase("discarded batch keeps proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Inner #1 (ticket-based) would succeed; inner #2 fails, so + // tfAllOrNothing discards every inner change, ticket included. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfAllOrNothing), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1'000'000)), seq + 1)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + // The ticket survived the discard. + BEAST_EXPECT(ownerCount(env, target) == 1); + + // The resurrected ticket still triggers cleanup when it is finally + // consumed for real. + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // Under tfIndependent a failed inner transaction is still applied as a + // claimed-fee result, durably consuming its ticket — so the proposal + // must be deleted even though the inner transaction failed. + void + testBatchInnerTecStillDeletes(FeatureBitset features) + { + testcase("failed independent inner transaction still deletes proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfIndependent), + batch::Inner(pay(target, bob, XRP(1'000'000)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1)), seq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // tfUntilFailure applies inner transactions up to the first failure: + // tickets consumed before the break delete their proposals; tickets never + // reached keep theirs. + void + testBatchPartialConsumption(FeatureBitset features) + { + testcase("partially applied batch deletes only consumed tickets' proposals"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 2)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq + 1), expiration)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + + // Inner #1 consumes the first ticket, inner #2 fails and stops the + // batch, inner #3 (second ticket) is never attempted. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 3); + env(batch::outer(target, seq, batchFee, tfUntilFailure), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1'000'000)), seq + 1), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + // The unreached ticket survives. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A delegate (sfDelegate) submits and pays for the transaction, but the + // ticket consumed belongs to the delegating account — the hook must key + // on the ticket owner, not on the submitter or fee payer. + void + testDelegatedTicketSpend(FeatureBitset features) + { + testcase("delegated transaction ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const marty{"marty"}; // target's delegate + env.fund(XRP(10000), alice, target, bob, marty); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env(delegate::set(target, marty, {"Payment"})); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(pay(target, bob, XRP(1)), delegate::As(marty), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + // The target's one remaining object is the Delegate entry itself. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A fee-sponsored transaction charges its fee to the sponsor while the + // account's own ticket is consumed — the hook must follow the ticket. + void + testFeeSponsoredTicketSpend(FeatureBitset features) + { + testcase("fee-sponsored transaction ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const spons{"sponsor"}; + env.fund(XRP(10000), alice, target, bob, spons); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(pay(target, bob, XRP(1)), + ticket::Use(ticketSeq), + Fee(XRP(1)), + sponsor::As(spons, spfSponsorFee), + Sig(sfSponsorSignature, spons)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // A TicketCreate submitted using a ticket consumes it like any other + // transaction, then mints fresh tickets; only the consumed ticket's + // proposal goes away. + void + testTicketCreateViaTicket(FeatureBitset features) + { + testcase("ticket-funded TicketCreate deletes consumed ticket's proposal"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(ticket::create(target, 1), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + // The freshly minted ticket is the target's only object. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A proposal that is already terminal (Expiration passed) is still + // deleted when its ticket is consumed: the hook deliberately has no + // terminal-state check, and XLS-0103 §4.5 makes automatic cleanup + // unconditional. + void + testExpiredProposalStillDeleted(FeatureBitset features) + { + testcase("expired proposal still deleted on ticket spend"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 60s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Sail past the expiration: the proposal is terminal but stays in + // ledger state (nothing cleans up on expiry by itself). + env.close(env.now() + 120s); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // With more than 32 owned objects the proposer's directory spans multiple + // pages, so deletion must honor the sfOwnerNode page hint stored at + // creation rather than assume the root page. + void + testMultiPageOwnerDirectory(FeatureBitset features) + { + testcase("proposal on a non-root owner directory page"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + // Fill alice's owner directory past one page (32 entries) before the + // proposal is created. + env(ticket::create(alice, 40)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), ticketSeq)); + BEAST_EXPECT(sle); + if (!sle) + return; + // The shape under test: the proposal must have landed off the root + // directory page. + BEAST_EXPECT((*sle)[sfOwnerNode] != 0); + BEAST_EXPECT(ownerCount(env, alice) == 40 + kProposalOwnerCount); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 40); + } + // No sponsored-proposal case: a TransactionProposal cannot carry a // reserve sponsor today — ttTRANSACTION_PROPOSAL_CREATE is not in the v1 // reserve-sponsorship allow-list (isReserveSponsorAllowed) and @@ -447,6 +799,14 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite testTargetAccountDeleted(testableAmendments()); testProposerIsTarget(testableAmendments()); testProposalBlocksOwnerAccountDelete(testableAmendments()); + testBatchDiscardKeepsProposal(testableAmendments()); + testBatchInnerTecStillDeletes(testableAmendments()); + testBatchPartialConsumption(testableAmendments()); + testDelegatedTicketSpend(testableAmendments()); + testFeeSponsoredTicketSpend(testableAmendments()); + testTicketCreateViaTicket(testableAmendments()); + testExpiredProposalStillDeleted(testableAmendments()); + testMultiPageOwnerDirectory(testableAmendments()); } }; From 548b8508725b665103c92cfdd36d68650edcc18e Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Aug 2026 14:14:46 -0700 Subject: [PATCH 19/21] fix: Adopt the xrpl::proposal namespace from the ripple/cosign merge Co-Authored-By: Claude Fable 5 --- src/libxrpl/tx/Transactor.cpp | 2 +- .../transactors/proposal/ProposalHelpers.cpp | 6 ++--- .../TransactionProposalAutoDelete_test.cpp | 22 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index a852ecf35c8..e5d1a64ac4d 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -879,7 +879,7 @@ Transactor::ticketDelete( { if (auto const sleProposal = view.peek(keylet::txProposal(account, ticketSeq))) { - if (TER const ter = deleteProposal(view, sleProposal, j); !isTesSuccess(ter)) + if (TER const ter = proposal::deleteProposal(view, sleProposal, j); !isTesSuccess(ter)) return ter; // LCOV_EXCL_LINE } } diff --git a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp index 33b9119ae0d..748b2d15644 100644 --- a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp +++ b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp @@ -15,7 +15,7 @@ #include -namespace xrpl { +namespace xrpl::proposal { TER deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j) @@ -27,7 +27,7 @@ deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal XRPL_ASSERT( sleProposal && sleProposal->getType() == ltTRANSACTION_PROPOSAL && view.exists(Keylet{ltTRANSACTION_PROPOSAL, sleProposal->key()}), - "xrpl::deleteProposal : valid proposal sle of this view"); + "xrpl::proposal::deleteProposal : valid proposal sle of this view"); AccountID const owner = sleProposal->getAccountID(sfOwner); @@ -62,4 +62,4 @@ deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal return tesSUCCESS; } -} // namespace xrpl +} // namespace xrpl::proposal diff --git a/src/test/app/TransactionProposalAutoDelete_test.cpp b/src/test/app/TransactionProposalAutoDelete_test.cpp index f5fcd5094f4..0c50ce33373 100644 --- a/src/test/app/TransactionProposalAutoDelete_test.cpp +++ b/src/test/app/TransactionProposalAutoDelete_test.cpp @@ -102,7 +102,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); // Each proposal reserves several owner increments; the target owns // only its two Tickets. - BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == 2 * proposal::kProposalOwnerCount); BEAST_EXPECT(ownerCount(env, target) == 2); // A sequence-based transaction of the target consumes no ticket, so @@ -121,7 +121,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); BEAST_EXPECT(ownerCount(env, target) == 1); // The target spends the second ticket on something unrelated to the @@ -179,7 +179,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); } // A ticket is consumed even when its transaction fails with a tec, and @@ -266,7 +266,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env(proposalCreate(alice, proposedTx, expiration)); env.close(); - BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kBatchProposalOwnerCount); env(noop(target), ticket::Use(ticketSeq)); env.close(); @@ -348,7 +348,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); env.close(); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); incLgrSeqForAccDel(env, target); env(acctdelete(target, bob), Fee(env.current()->fees().increment)); @@ -389,7 +389,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite auto const sle = env.le(keylet::txProposal(alice.id(), ticketSeq)); BEAST_EXPECT(sle && sle->getAccountID(sfOwner) == alice.id()); // One Ticket plus the proposal's increments, all against alice. - BEAST_EXPECT(ownerCount(env, alice) == 1 + kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == 1 + proposal::kProposalOwnerCount); env(noop(alice), ticket::Use(ticketSeq)); env.close(); @@ -435,7 +435,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(env.le(keylet::account(alice.id()))); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); } // A tfAllOrNothing Batch whose inner transaction spends the proposal's @@ -476,7 +476,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); // The ticket survived the discard. BEAST_EXPECT(ownerCount(env, target) == 1); @@ -556,7 +556,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq + 1), expiration)); env.close(); - BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == 2 * proposal::kProposalOwnerCount); // Inner #1 consumes the first ticket, inner #2 fails and stops the // batch, inner #3 (second ticket) is never attempted. @@ -570,7 +570,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); - BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); // The unreached ticket survives. BEAST_EXPECT(ownerCount(env, target) == 1); } @@ -770,7 +770,7 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite // The shape under test: the proposal must have landed off the root // directory page. BEAST_EXPECT((*sle)[sfOwnerNode] != 0); - BEAST_EXPECT(ownerCount(env, alice) == 40 + kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, alice) == 40 + proposal::kProposalOwnerCount); env(noop(target), ticket::Use(ticketSeq)); env.close(); From 1da81b7a449f44c48eacf91a8294dbe8c88adc10 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Wed, 5 Aug 2026 15:41:43 -0700 Subject: [PATCH 20/21] test: Rename variable to satisfy cspell hook Co-Authored-By: Claude Fable 5 --- src/test/app/TransactionProposalAutoDelete_test.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/app/TransactionProposalAutoDelete_test.cpp b/src/test/app/TransactionProposalAutoDelete_test.cpp index 0c50ce33373..dcbed571516 100644 --- a/src/test/app/TransactionProposalAutoDelete_test.cpp +++ b/src/test/app/TransactionProposalAutoDelete_test.cpp @@ -629,8 +629,8 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite Account const alice{"alice"}; Account const target{"target"}; Account const bob{"bob"}; - Account const spons{"sponsor"}; - env.fund(XRP(10000), alice, target, bob, spons); + Account const payer{"sponsor"}; + env.fund(XRP(10000), alice, target, bob, payer); env.close(); std::uint32_t const ticketSeq = env.seq(target) + 1; @@ -645,8 +645,8 @@ struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite env(pay(target, bob, XRP(1)), ticket::Use(ticketSeq), Fee(XRP(1)), - sponsor::As(spons, spfSponsorFee), - Sig(sfSponsorSignature, spons)); + sponsor::As(payer, spfSponsorFee), + Sig(sfSponsorSignature, payer)); env.close(); BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); From a17264c7ffba5d98fc79464d13881d888727619a Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Thu, 6 Aug 2026 15:50:18 -0700 Subject: [PATCH 21/21] Add transaction_proposal RPC reporting proposal completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluates a TransactionProposal's collected signatures against live ledger state (XLS-0103 §8): per-account signing status with multisig quorum progress, Batch participant decomposition, counterparty and sponsor co-signatures, and terminal-first expiry. Co-Authored-By: Claude Fable 5 --- include/xrpl/protocol/jss.h | 8 +- include/xrpl/tx/Transactor.h | 33 +- .../tx/transactors/proposal/ProposalHelpers.h | 71 ++ .../transactors/proposal/ProposalHelpers.cpp | 278 ++++++ src/test/rpc/TransactionProposal_test.cpp | 936 ++++++++++++++++++ src/xrpld/rpc/detail/Handler.cpp | 4 + src/xrpld/rpc/handlers/Handlers.h | 2 + .../rpc/handlers/TransactionProposal.cpp | 146 +++ 8 files changed, 1463 insertions(+), 15 deletions(-) create mode 100644 src/test/rpc/TransactionProposal_test.cpp create mode 100644 src/xrpld/rpc/handlers/TransactionProposal.cpp diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..f997e6d3abd 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -482,6 +482,9 @@ JSS(previous); // out: Reservations JSS(previous_ledger); // out: LedgerPropose JSS(price); // out: amm_info, AuctionSlot JSS(proof); // in: BookOffers +JSS(proposal); // out: TransactionProposal +JSS(proposal_id); // in/out: TransactionProposal +JSS(proposal_status); // out: TransactionProposal JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus JSS(protocol); // out: NetworkOPs, PeerImp @@ -502,6 +505,7 @@ JSS(queue); // in: AccountInfo JSS(queue_data); // out: AccountInfo JSS(queued); // out: SubmitTransaction JSS(queued_duration_us); // +JSS(quorum); // out: TransactionProposal JSS(quote_asset); // in: get_aggregate_price JSS(random); // out: Random JSS(raw_meta); // out: AcceptedLedgerTx @@ -524,7 +528,7 @@ JSS(result); // RPC JSS(ripple_lines); // out: NetworkOPs JSS(ripple_state); // in: LedgerEntr JSS(ripplerpc); // XRPL RPC version -JSS(role); // out: Ping.cpp +JSS(role); // out: Ping.cpp, TransactionProposal JSS(rpc); // JSS(rt_accounts); // in: Subscribe, Unsubscribe JSS(running_duration_us); // @@ -552,8 +556,10 @@ JSS(shares); // out: VaultInfo JSS(signature); // out: NetworkOPs, ChannelAuthorize JSS(signature_target); // in: TransactionSign JSS(signature_verified); // out: ChannelVerify +JSS(signed_weight); // out: TransactionProposal JSS(signing_key); // out: NetworkOPs JSS(signing_keys); // out: ValidatorList +JSS(signing_status); // out: TransactionProposal JSS(signing_time); // out: NetworkOPs JSS(signer_lists); // in/out: AccountInfo JSS(size); // out: get_aggregate_price diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index d2d45197640..7c136f2eaa0 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -228,6 +228,25 @@ class Transactor static NotTEC checkSign(PreclaimContext const& ctx); + // Whether sigObject's signature fields currently authorize idAccount on + // this view. Public because it answers a pure ledger-state question, so + // read-only callers (e.g. the transaction_proposal RPC reporting a + // proposal's completeness) share one authorization rule with the + // transaction path and the two cannot drift. + static NotTEC + checkSign( + ReadView const& view, + ApplyFlags flags, + std::optional const& parentBatchId, + AccountID const& idAccount, + STObject const& sigObject, + beast::Journal const j, + // A batch may carry an inner from an account that an earlier inner + // creates, so the signer account need not exist yet; when it does not, + // only its own master key may authorize it. Normal transactions require + // the account to already exist. + bool permitUncreatedAccount = false); + // Returns the fee in fee units, not scaled for load. static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); @@ -418,20 +437,6 @@ class Transactor static XRPAmount calculateOwnerReserveFee(ReadView const& view, STTx const& tx); - static NotTEC - checkSign( - ReadView const& view, - ApplyFlags flags, - std::optional const& parentBatchId, - AccountID const& idAccount, - STObject const& sigObject, - beast::Journal const j, - // A batch may carry an inner from an account that an earlier inner - // creates, so the signer account need not exist yet; when it does not, - // only its own master key may authorize it. Normal transactions require - // the account to already exist. - bool permitUncreatedAccount = false); - // Base class always returns true static bool checkExtraFeatures(PreflightContext const& ctx); diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h index a7d505a74e7..e08de4edebe 100644 --- a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -9,6 +11,8 @@ #include #include +#include +#include namespace xrpl::proposal { @@ -99,4 +103,71 @@ proposalOwnerCount(STObject const& proposedTx) TER deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j); +/** + * The role in which an account's authorization is required on a proposal. + */ +enum class SignerRole : std::uint8_t { + account, ///< the proposed transaction's initiator (Account, or Delegate if present) + batchParticipant, ///< an inner transaction's initiator in a proposed Batch + counterparty, ///< a Counterparty of the proposed transaction or of an inner + sponsor, ///< a co-signing Sponsor of the proposed transaction or of an inner +}; + +/** + * One required authorization on a proposal and whether the signatures + * collected so far currently satisfy it. + */ +struct SignerStatus +{ + AccountID account; + SignerRole role; + /// Whether the collected signature material authorizes `account` on the + /// evaluated ledger (same rule the transaction path applies at preclaim). + bool satisfied = false; + /// Multi-signature progress: the weight the collected Signers entries + /// carry against `account`'s live SignerList. Present only when the + /// collected signature object holds a Signers array. + std::optional signedWeight; + /// `account`'s live SignerQuorum. Present only when the account has a + /// SignerList on the evaluated ledger. + std::optional quorum; +}; + +/** + * Completeness state of a proposal (XLS-0103 §8.1.2). Terminal-first: an + * expired proposal reports expired even when fully signed. + */ +enum class ProposalState : std::uint8_t { pending, complete, expired }; + +/** + * A proposal's completeness state plus the per-account detail it derives + * from. + */ +struct ProposalStatus +{ + ProposalState state = ProposalState::pending; + std::vector signers; +}; + +/** + * Evaluate how far a TransactionProposal's collected signatures are from a + * submittable transaction on the given ledger. + * + * Signatures stored on the proposal were cryptographically verified when they + * were appended, so this only re-checks their authorization against live + * ledger state (SignerList membership and quorum, regular-key rotation, + * disabled master keys), mirroring what Transactor::checkSign would decide at + * submission time. For a proposed Batch, each inner initiator, counterparty, + * and co-signing sponsor other than the outer account is a separate required + * authorization collected through BatchSigners, mirroring + * Batch::preflightSigValidated. A LoanSet without an explicit Counterparty + * requires the owner of its LoanBroker instead, mirroring LoanSet::checkSign. + * + * @param view The ledger to evaluate against. + * @param sleProposal A TransactionProposal ledger entry of that ledger. + * @param j Journal for logging. + */ +ProposalStatus +evaluateProposal(ReadView const& view, SLE const& sleProposal, beast::Journal j); + } // namespace xrpl::proposal diff --git a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp index 748b2d15644..3f05079be3b 100644 --- a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp +++ b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp @@ -4,16 +4,27 @@ #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::proposal { @@ -62,4 +73,271 @@ deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal return tesSUCCESS; } +namespace { + +/** + * Whether the signature object is well-formed enough to hand to + * Transactor::checkSign. Bare or partially-filled objects (a proposal + * accumulates signatures over time) are simply "not signed yet". Shapes the + * transaction path asserts rather than checks — a single signature without a + * SigningPubKey, a Signers entry with an empty one, TxnSignature alongside + * Signers — must be treated as unauthorized instead of reaching those + * assertions from a read-only path. + */ +bool +authorizationCheckable(STObject const& sigObject) +{ + bool const hasTxnSignature = sigObject.isFieldPresent(sfTxnSignature); + if (sigObject.isFieldPresent(sfSigners)) + { + if (hasTxnSignature) + return false; + auto const& signers = sigObject.getFieldArray(sfSigners); + return !signers.empty() && std::ranges::all_of(signers, [](STObject const& signer) { + return signer.isFieldPresent(sfAccount) && signer.isFieldPresent(sfSigningPubKey) && + !signer.getFieldVL(sfSigningPubKey).empty(); + }); + } + return hasTxnSignature && sigObject.isFieldPresent(sfSigningPubKey) && + !sigObject.getFieldVL(sfSigningPubKey).empty(); +} + +/** + * Report multi-signature progress: the account's live SignerQuorum, and the + * weight the collected Signers entries hold against that live list. Entries + * that are no longer on the list contribute nothing, matching how they would + * fare at submission time. + */ +void +reportMultiSignProgress( + ReadView const& view, + AccountID const& account, + STObject const* sigObject, + SignerStatus& status, + beast::Journal j) +{ + auto const sleList = view.read(keylet::signerList(account)); + if (sleList) + status.quorum = sleList->getFieldU32(sfSignerQuorum); + + if (!sigObject || !sigObject->isFieldPresent(sfSigners)) + return; + + std::uint32_t weight = 0; + if (sleList) + { + if (auto const entries = SignerEntries::deserialize(*sleList, j, "ledger")) + { + for (STObject const& signer : sigObject->getFieldArray(sfSigners)) + { + AccountID const id = signer.getAccountID(sfAccount); + auto const it = std::ranges::find_if( + *entries, [&id](auto const& entry) { return entry.account == id; }); + if (it != entries->end()) + weight += it->weight; + } + } + } + status.signedWeight = weight; +} + +/** + * Whether the signature material collected for one account currently + * authorizes it, plus its multi-signature progress. sigObject is null when no + * signature has been collected for the account yet. + */ +SignerStatus +evaluateAuthorization( + ReadView const& view, + AccountID const& account, + SignerRole role, + STObject const* sigObject, + bool permitUncreatedAccount, + beast::Journal j) +{ + SignerStatus status{.account = account, .role = role}; + + // Malformed on-ledger signature material must degrade to "not satisfied" + // rather than fail the caller (an RPC), so every field access on + // sigObject stays inside this try. + try + { + reportMultiSignProgress(view, account, sigObject, status, j); + + if (sigObject && authorizationCheckable(*sigObject)) + { + // The same authorization rule the transaction path applies at + // preclaim; crypto validity was already checked when the + // signature was appended to the proposal. + status.satisfied = isTesSuccess(Transactor::checkSign( + view, TapNone, std::nullopt, account, *sigObject, j, permitUncreatedAccount)); + } + } + catch (std::exception const& e) + { + JLOG(j.warn()) << "evaluateAuthorization: signature material for " << toBase58(account) + << " is malformed: " << e.what(); + status.satisfied = false; + } + + return status; +} + +} // namespace + +ProposalStatus +evaluateProposal(ReadView const& view, SLE const& sleProposal, beast::Journal j) +{ + XRPL_ASSERT( + sleProposal.getType() == ltTRANSACTION_PROPOSAL, + "xrpl::proposal::evaluateProposal : a TransactionProposal entry"); + + ProposalStatus result; + STObject const proposedTx = sleProposal.getFieldObject(sfProposedTransaction); + + AccountID const initiator = proposedTx.isFieldPresent(sfDelegate) + ? proposedTx.getAccountID(sfDelegate) + : proposedTx.getAccountID(sfAccount); + + // The initiator's authorization lives in the top-level signature fields. + // Evaluate them without the sponsor's signature: that is a separate + // authorization reported on its own row, and Transactor::checkSign would + // otherwise fold its validity into the initiator's. + { + STObject topLevel = proposedTx; + if (topLevel.isFieldPresent(sfSponsorSignature)) + topLevel.makeFieldAbsent(sfSponsorSignature); + result.signers.push_back(evaluateAuthorization( + view, initiator, SignerRole::account, &topLevel, /*permitUncreatedAccount=*/false, j)); + } + + // The required auxiliary co-signer, who signs through + // CounterpartySignature: the explicit Counterparty or — for a LoanSet, + // the only type with an implicit one — the owner of its LoanBroker + // (mirrors LoanSet::checkSign). The implicit rule is keyed on the + // transaction type, not on sfLoanBrokerID: the LoanBroker* types carry + // that field too but require no counterparty. + std::optional counterparty = proposedTx[~sfCounterparty]; + if (!counterparty && proposedTx.getFieldU16(sfTransactionType) == ttLOAN_SET && + proposedTx.isFieldPresent(sfLoanBrokerID)) + { + if (auto const broker = + view.read(keylet::loanBroker(proposedTx.getFieldH256(sfLoanBrokerID)))) + counterparty = broker->at(sfOwner); + } + if (counterparty) + { + std::optional const sig = proposedTx.isFieldPresent(sfCounterpartySignature) + ? std::optional(proposedTx.getFieldObject(sfCounterpartySignature)) + : std::nullopt; + result.signers.push_back(evaluateAuthorization( + view, + *counterparty, + SignerRole::counterparty, + sig ? &*sig : nullptr, + /*permitUncreatedAccount=*/false, + j)); + } + + // A Sponsor either co-signs through SponsorSignature or is pre-authorized + // by an on-ledger Sponsorship entry whose flags do not demand a signature + // for what this transaction sponsors (mirrors Transactor::checkSponsor). + if (proposedTx.isFieldPresent(sfSponsor)) + { + AccountID const sponsor = proposedTx.getAccountID(sfSponsor); + std::optional const sig = proposedTx.isFieldPresent(sfSponsorSignature) + ? std::optional(proposedTx.getFieldObject(sfSponsorSignature)) + : std::nullopt; + auto status = evaluateAuthorization( + view, sponsor, SignerRole::sponsor, sig ? &*sig : nullptr, false, j); + // The pre-authorization fallback applies only while no + // SponsorSignature has been collected: once the field exists, + // Transactor::checkSign validates it unconditionally at submission, + // so a failing (e.g. stale-key) signature must not be rescued here. + if (!status.satisfied && !sig) + { + std::uint32_t const sponsorFlags = + proposedTx.isFieldPresent(sfSponsorFlags) ? proposedTx.getFieldU32(sfSponsorFlags) : 0; + if (auto const sleSponsorship = view.read(keylet::sponsorship(sponsor, initiator))) + { + bool const feeNeedsSig = ((sponsorFlags & spfSponsorFee) != 0u) && + sleSponsorship->isFlag(lsfSponsorshipRequireSignForFee); + bool const reserveNeedsSig = ((sponsorFlags & spfSponsorReserve) != 0u) && + sleSponsorship->isFlag(lsfSponsorshipRequireSignForReserve); + status.satisfied = !feeNeedsSig && !reserveNeedsSig; + } + } + result.signers.push_back(std::move(status)); + } + + // A proposed Batch needs each inner initiator, counterparty, and + // co-signing sponsor other than the outer account to authorize through a + // BatchSigners entry (mirrors Batch::preflightSigValidated). BatchSigners + // entries that no required account matches are ignored here: the Sign + // transaction never stores one, and completeness cannot come from them. + if (proposedTx.getFieldU16(sfTransactionType) == ttBATCH) + { + AccountID const outerAccount = proposedTx.getAccountID(sfAccount); + + std::vector> required; + auto const addRequired = [&outerAccount, &required](AccountID const& id, SignerRole role) { + if (id == outerAccount) + return; + if (std::ranges::none_of( + required, [&id](auto const& entry) { return entry.first == id; })) + required.emplace_back(id, role); + }; + for (STObject const& rb : proposedTx.getFieldArray(sfRawTransactions)) + { + addRequired( + rb.isFieldPresent(sfDelegate) ? rb.getAccountID(sfDelegate) + : rb.getAccountID(sfAccount), + SignerRole::batchParticipant); + if (auto const counterparty = rb[~sfCounterparty]) + addRequired(*counterparty, SignerRole::counterparty); + if (rb.isFieldPresent(sfSponsor) && rb.isFieldPresent(sfSponsorSignature)) + addRequired(rb.getAccountID(sfSponsor), SignerRole::sponsor); + } + + STArray const* const batchSigners = proposedTx.isFieldPresent(sfBatchSigners) + ? &proposedTx.getFieldArray(sfBatchSigners) + : nullptr; + auto const findBatchSigner = [batchSigners](AccountID const& id) -> STObject const* { + if (!batchSigners) + return nullptr; + auto const it = std::ranges::find_if(*batchSigners, [&id](STObject const& signer) { + return signer.getAccountID(sfAccount) == id; + }); + return it != batchSigners->end() ? &*it : nullptr; + }; + + for (auto const& [id, role] : required) + { + // permitUncreatedAccount: an earlier inner transaction may create + // the signer's account, so authorization by its own master key + // must count (mirrors Batch::checkBatchSign). + result.signers.push_back(evaluateAuthorization( + view, id, role, findBatchSigner(id), /*permitUncreatedAccount=*/true, j)); + } + } + + // Terminal-first (XLS-0103 §8.1.2): a proposal past its Expiration or its + // transaction's LastLedgerSequence reports expired even if fully signed. + // An open view can still include the transaction itself; a closed one + // only in a successor, matching the tefMAX_LEDGER rule (seq > LLS fails). + std::uint32_t const earliestSeq = view.seq() + (view.open() ? 0 : 1); + bool const expired = hasExpired(view, sleProposal[~sfExpiration]) || + (proposedTx.isFieldPresent(sfLastLedgerSequence) && + proposedTx.getFieldU32(sfLastLedgerSequence) < earliestSeq); + + if (expired) + result.state = ProposalState::expired; + else if (std::ranges::all_of(result.signers, [](auto const& s) { return s.satisfied; })) + result.state = ProposalState::complete; + else + result.state = ProposalState::pending; + + return result; +} + } // namespace xrpl::proposal diff --git a/src/test/rpc/TransactionProposal_test.cpp b/src/test/rpc/TransactionProposal_test.cpp new file mode 100644 index 00000000000..c821a524eb3 --- /dev/null +++ b/src/test/rpc/TransactionProposal_test.cpp @@ -0,0 +1,936 @@ +#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 + +namespace xrpl::test { + +struct TransactionProposalRPC_test : public beast::unit_test::Suite +{ + static constexpr auto kSigned = "signed"; + + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + + // Parse a TransactionProposalCreate through the transaction machinery + // (without submitting it) and extract the proposed transaction as the + // typed STObject the ledger would store. + static STObject + parsedPayload(jtx::Env& env, jtx::Account const& proposer, json::Value const& proposedTx) + { + auto const jt = + env.jt(proposalCreate(proposer, proposedTx, (env.now() + std::chrono::seconds(1000)).time_since_epoch().count())); + return jt.stx->getFieldObject(sfProposedTransaction); + } + + // A TransactionProposal ledger entry as TransactionProposalSign would + // leave it after appending signatures: the evaluator only reads + // ProposedTransaction and Expiration, everything else is boilerplate. + static std::shared_ptr + makeProposalSLE( + jtx::Account const& owner, + STObject const& proposedTx, + std::uint32_t expiration) + { + auto const target = proposedTx.getAccountID(sfAccount); + auto const ticketSeq = proposedTx.getFieldU32(sfTicketSequence); + auto sle = std::make_shared(keylet::txProposal(target, ticketSeq)); + sle->setAccountID(sfOwner, owner.id()); + sle->setFieldObject(sfProposedTransaction, proposedTx); + sle->setFieldU32(sfExpiration, expiration); + sle->setFieldU64(sfOwnerNode, 0); + sle->setFieldH256(sfPreviousTxnID, uint256{}); + sle->setFieldU32(sfPreviousTxnLgrSeq, 0); + return sle; + } + + static std::uint32_t + farFuture(jtx::Env& env) + { + return (env.now() + std::chrono::seconds(1000)).time_since_epoch().count(); + } + + // Single-signature material: the crypto was verified when the signature + // was appended on-ledger, so the evaluator only inspects the public key. + static void + singleSign(STObject& obj, PublicKey const& pk) + { + obj.setFieldVL(sfSigningPubKey, pk.slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + } + + static STObject + makeSignerEntry(jtx::Account const& acct) + { + STObject obj(sfSigner); + obj.setAccountID(sfAccount, acct.id()); + obj.setFieldVL(sfSigningPubKey, acct.pk().slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + return obj; + } + + // Multi-signature material: Signers sorted by account ID, as the ledger + // stores them. + static void + multiSign(STObject& obj, std::vector accounts) + { + std::sort(accounts.begin(), accounts.end(), [](auto const& a, auto const& b) { + return a.id() < b.id(); + }); + STArray signers(sfSigners); + for (auto const& acct : accounts) + signers.push_back(makeSignerEntry(acct)); + obj.setFieldArray(sfSigners, signers); + } + + static STObject + makeBatchSigner(jtx::Account const& acct) + { + STObject obj(sfBatchSigner); + obj.setAccountID(sfAccount, acct.id()); + obj.setFieldVL(sfSigningPubKey, acct.pk().slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + return obj; + } + + static proposal::ProposalStatus + evaluate(jtx::Env& env, std::shared_ptr const& sle) + { + return proposal::evaluateProposal(*env.current(), *sle, env.journal); + } + + static proposal::SignerStatus const* + findSigner(proposal::ProposalStatus const& status, AccountID const& id) + { + for (auto const& signer : status.signers) + if (signer.account == id) + return &signer; + return nullptr; + } + + void + testMalformedRequests(FeatureBitset features) + { + testcase("malformed requests"); + + using namespace jtx; + Env env{*this, features}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + auto const rpc = [&](json::Value const& params) { + return env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + }; + + // No addressing fields at all. + { + json::Value params{json::ValueType::Object}; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // proposal_id is not hex. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = "not-hex"; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedRequest"); + } + + // proposal_id combined with account/ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(uint256{1}); + params[jss::account] = alice.human(); + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // account without ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = alice.human(); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // Malformed account. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = "rNotAnAccount!!!"; + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedAddress"); + } + + // account of a wrong JSON type must be a parameter error, not an + // internal one. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = 42; + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedAddress"); + } + + // ticket_seq that does not parse as a number. (Numeric strings are + // accepted, matching ledger_entry's transaction_proposal addressing.) + { + json::Value params{json::ValueType::Object}; + params[jss::account] = alice.human(); + params[jss::ticket_seq] = "one"; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedRequest"); + } + + // Well-formed but nonexistent. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(uint256{42}); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "entryNotFound"); + } + + // An index that names a different ledger entry type: reads as absent, + // not as a proposal. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(keylet::account(alice.id()).key); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "entryNotFound"); + } + } + + void + testPendingUnsigned(FeatureBitset features) + { + testcase("pending unsigned proposal via RPC"); + + 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), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const proposalKey = keylet::txProposal(target.id(), ticketSeq).key; + + auto const check = [&](json::Value const& jrr) { + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + BEAST_EXPECT(jrr[jss::proposal_id] == to_string(proposalKey)); + BEAST_EXPECT( + jrr[jss::proposal][sfOwner.getJsonName()] == alice.human()); + auto const& signers = jrr[jss::signing_status]; + BEAST_EXPECT(signers.isArray() && signers.size() == 1); + BEAST_EXPECT(signers[0u][jss::account] == target.human()); + BEAST_EXPECT(signers[0u][jss::role] == "account"); + BEAST_EXPECT(signers[0u][kSigned] == false); + // No SignerList: no quorum to report. + BEAST_EXPECT(!signers[0u].isMember(jss::quorum)); + }; + + // By proposal_id. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(proposalKey); + check(env.rpc("json", "transaction_proposal", to_string(params))[jss::result]); + } + + // By account + ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + check(env.rpc("json", "transaction_proposal", to_string(params))[jss::result]); + } + + // Once the target sets a SignerList, its live quorum is reported. + env(signers(target, 2, {{bob, 1}, {alice, 1}})); + env.close(); + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(proposalKey); + auto const jrr = env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + auto const& signerStatus = jrr[jss::signing_status]; + BEAST_EXPECT(signerStatus[0u][jss::quorum] == 2); + } + } + + void + testExpiredStates(FeatureBitset features) + { + testcase("expired proposal"); + + 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), alice, target, bob); + env.close(); + + // Expiration reached: terminal even though nothing else changed. + { + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 60s).time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Pass the expiration time. + env.close(env.now() + 120s); + + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + auto const jrr = env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "expired"); + } + + // The proposed transaction's own LastLedgerSequence has passed: the + // transaction can never enter a ledger, so the proposal is terminal + // even though its Expiration is still far away. + { + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value payload = unsignedPayload(env, target, bob, ticketSeq); + std::uint32_t const lastLedgerSeq = env.current()->seq() + 2; + payload[jss::LastLedgerSequence] = lastLedgerSeq; + + std::uint32_t const expiration = (env.now() + 1000s).time_since_epoch().count(); + env(proposalCreate(alice, payload, expiration)); + env.close(); + env.close(); + + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + + // Boundary: the RPC's default ledger is the open ledger, which + // the proposed transaction can still enter when its + // LastLedgerSequence equals that ledger's sequence. + BEAST_EXPECT(env.current()->seq() == lastLedgerSeq); + { + auto const jrr = + env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + } + + // One ledger later the bound has passed for good. + env.close(); + { + auto const jrr = + env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "expired"); + } + } + } + + void + testSingleSignAuthorization(FeatureBitset features) + { + testcase("single-signature authorization currency"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const regular{"regular"}; + env.fund(XRP(10000), alice, target, bob); + // The regular key account never exists on ledger; jtx only needs to + // know its keys to sign with them once the RegularKey is set. + env.memoize(regular); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const base = parsedPayload(env, alice, unsignedPayload(env, target, bob, ticketSeq)); + + // Unsigned: pending. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(!status.signers[0].satisfied); + } + + // Master-key signed: complete. + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // A key unrelated to the target signs: not authorized. + { + STObject payload = base; + singleSign(payload, bob.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // Regular-key signed: complete once the key is set... + env(regkey(target, regular)); + env.close(); + { + STObject payload = base; + singleSign(payload, regular.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // ...and the master key still works while enabled... + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // ...but a master signature collected earlier no longer authorizes + // once the master key is disabled: authorization is re-checked + // against live state. + // Disabling the master key must itself be signed with the master key + // (jtx would otherwise sign with the regular key set above). + env(fset(target, asfDisableMaster), Sig(target)); + env.close(); + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // Terminal-first: a fully signed proposal past its expiration reports + // expired, not complete. + { + STObject payload = base; + singleSign(payload, regular.pk()); + std::uint32_t const past = (env.now() - std::chrono::seconds(10)).time_since_epoch().count(); + auto const status = evaluate(env, makeProposalSLE(alice, payload, past)); + BEAST_EXPECT(status.state == proposal::ProposalState::expired); + } + } + + void + testMultiSignAuthorization(FeatureBitset features) + { + testcase("multi-signature quorum against the live SignerList"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const dest{"dest"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + Account const outsider{"outsider"}; + env.fund(XRP(10000), alice, target, dest, bob, carol, dave, outsider); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const base = parsedPayload(env, alice, unsignedPayload(env, target, dest, ticketSeq)); + + // No SignerList yet: collected Signers cannot authorize anything. + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(!status.signers[0].quorum.has_value()); + BEAST_EXPECT(status.signers[0].signedWeight == 0); + } + + env(signers(target, 2, {{bob, 1}, {carol, 1}, {dave, 1}})); + env.close(); + + // One of three signers: quorum not met; progress is reported. + { + STObject payload = base; + multiSign(payload, {bob}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 1); + BEAST_EXPECT(status.signers[0].quorum == 2); + } + + // Two of three: quorum met. + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + BEAST_EXPECT(status.signers[0].signedWeight == 2); + } + + // A signer that is not on the live list poisons the whole set (the + // ordinary submission path rejects it wholesale), and contributes no + // weight. + { + STObject payload = base; + multiSign(payload, {bob, carol, outsider}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 2); + } + + // The list changed after signatures were collected: only the weight + // still on the live list counts. + env(signers(target, 2, {{bob, 1}, {dave, 1}})); + env.close(); + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 1); + } + } + + void + testDelegateAuthorization(FeatureBitset features) + { + testcase("delegated proposed transaction"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const delegate{"delegate"}; + Account const dest{"dest"}; + env.fund(XRP(10000), alice, target, delegate, dest); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value payload = unsignedPayload(env, target, dest, ticketSeq); + payload[sfDelegate.getJsonName()] = delegate.human(); + auto const base = parsedPayload(env, alice, payload); + + // The delegate, not the target, is the required signer. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(status.signers[0].account == delegate.id()); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // The delegate's own signature satisfies it; the target's does not. + { + STObject signedPayload = base; + singleSign(signedPayload, delegate.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, signedPayload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + { + STObject signedPayload = base; + singleSign(signedPayload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, signedPayload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + } + + void + testCounterpartyAuthorization(FeatureBitset features) + { + testcase("counterparty co-signature"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const borrower{"borrower"}; + Account const lender{"lender"}; + env.fund(XRP(10000), alice, borrower, lender); + env.close(); + + auto const makeLoanSet = [&](jtx::Account const& counterparty) { + std::uint32_t const ticketSeq = env.seq(borrower) + 1; + env(ticket::create(borrower, 1)); + env.close(); + + json::Value tx = loan::set(borrower, uint256{1}, 1'000); + tx[sfCounterparty.getJsonName()] = counterparty.human(); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return parsedPayload(env, alice, tx); + }; + + // Explicit Counterparty: a distinct required signer row. + { + auto const base = makeLoanSet(lender); + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 2); + auto const* row = findSigner(status, lender.id()); + BEAST_EXPECT(row && row->role == proposal::SignerRole::counterparty); + BEAST_EXPECT(!row->satisfied); + } + + // The lender co-signs through CounterpartySignature. + STObject counterSigned = base; + { + STObject sig(sfCounterpartySignature); + sig.setFieldVL(sfSigningPubKey, lender.pk().slice()); + sig.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + counterSigned.setFieldObject(sfCounterpartySignature, sig); + } + { + auto const status = + evaluate(env, makeProposalSLE(alice, counterSigned, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(findSigner(status, lender.id())->satisfied); + BEAST_EXPECT(!findSigner(status, borrower.id())->satisfied); + } + + // Both authorizations present: complete. + { + STObject payload = counterSigned; + singleSign(payload, borrower.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + // The implicit-counterparty rule is LoanSet's alone. Other types that + // carry sfLoanBrokerID (here LoanBrokerCoverDeposit) require no + // counterparty: exactly one signer row, and the initiator's own + // signature completes it. + { + std::uint32_t const ticketSeq = env.seq(borrower) + 1; + env(ticket::create(borrower, 1)); + env.close(); + + json::Value tx = loanBroker::coverDeposit(borrower, uint256{1}, XRP(100).value()); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + + STObject payload = parsedPayload(env, alice, tx); + singleSign(payload, borrower.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + testSponsorAuthorization(FeatureBitset features) + { + testcase("sponsor co-signature"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const patron{"patron"}; // the fee sponsor + env.fund(XRP(10000), alice, target, bob, patron); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value tx = unsignedPayload(env, target, bob, ticketSeq); + tx[sfSponsor.getJsonName()] = patron.human(); + tx[sfSponsorFlags.getJsonName()] = spfSponsorFee; + auto const base = parsedPayload(env, alice, tx); + + // The sponsor is a required signer; with no SponsorSignature and no + // pre-authorizing Sponsorship entry it is unsatisfied even when the + // target has signed. + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + auto const* row = findSigner(status, patron.id()); + BEAST_EXPECT(row && row->role == proposal::SignerRole::sponsor && !row->satisfied); + } + + // A bare SponsorSignature placeholder must not fall back to the + // Sponsorship-entry exemption: once the field exists, submission + // validates it unconditionally. + { + STObject payload = base; + singleSign(payload, target.pk()); + payload.setFieldObject(sfSponsorSignature, STObject(sfSponsorSignature)); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(!findSigner(status, patron.id())->satisfied); + } + + // The sponsor's own signature completes it. + { + STObject payload = base; + singleSign(payload, target.pk()); + STObject sig(sfSponsorSignature); + sig.setFieldVL(sfSigningPubKey, patron.pk().slice()); + sig.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + payload.setFieldObject(sfSponsorSignature, sig); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(findSigner(status, patron.id())->satisfied); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + testBatchAuthorization(FeatureBitset features) + { + testcase("proposed multi-account batch"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; // outer account of the batch + Account const bob{"bob"}; // a distinct inner participant + Account const carol{"carol"}; + Account const dave{"dave"}; + env.fund(XRP(10000), alice, target, bob, carol, dave); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const inner = [&](Account const& from, Account const& to, std::uint32_t seq) { + json::Value tx = pay(from, to, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = ticketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 1, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = + inner(target, bob, env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(bob, target, env.seq(bob)); + + auto const base = parsedPayload(env, alice, proposedTx); + + // Unsigned: the outer account and the distinct participant are both + // required; the inner from the outer account adds no extra row. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 2); + auto const* outer = findSigner(status, target.id()); + auto const* participant = findSigner(status, bob.id()); + BEAST_EXPECT(outer && outer->role == proposal::SignerRole::account && !outer->satisfied); + BEAST_EXPECT( + participant && participant->role == proposal::SignerRole::batchParticipant && + !participant->satisfied); + } + + // The outer account signs the batch itself; bob is still missing. + STObject outerSigned = base; + singleSign(outerSigned, target.pk()); + { + auto const status = evaluate(env, makeProposalSLE(alice, outerSigned, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(findSigner(status, target.id())->satisfied); + BEAST_EXPECT(!findSigner(status, bob.id())->satisfied); + } + + // Bob's single-signed BatchSigners entry completes the proposal. + { + STObject payload = outerSigned; + STArray batchSigners(sfBatchSigners); + batchSigners.push_back(makeBatchSigner(bob)); + payload.setFieldArray(sfBatchSigners, batchSigners); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // Bob authorizes through his own SignerList inside his BatchSigners + // entry: quorum computed per participant. + env(signers(bob, 2, {{carol, 1}, {dave, 1}})); + env.close(); + { + STObject payload = outerSigned; + + STObject bobSigner(sfBatchSigner); + bobSigner.setAccountID(sfAccount, bob.id()); + // Multi-signing canonical form: SigningPubKey present and empty. + bobSigner.setFieldVL(sfSigningPubKey, Blob{}); + { + STObject multi(sfBatchSigner); // temp holder for the array + multiSign(multi, {carol}); + bobSigner.setFieldArray(sfSigners, multi.getFieldArray(sfSigners)); + } + STArray batchSigners(sfBatchSigners); + batchSigners.push_back(bobSigner); + payload.setFieldArray(sfBatchSigners, batchSigners); + + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + auto const* participant = findSigner(status, bob.id()); + BEAST_EXPECT(participant->signedWeight == 1 && participant->quorum == 2); + } + + // An inner from an account that does not exist yet (an earlier inner + // could create it) may be authorized by its own master key. + { + Account const phantom{"phantom"}; // never funded + + json::Value withPhantom = proposedTx; + withPhantom[jss::RawTransactions][2u][jss::RawTransaction] = + inner(phantom, target, 1); + auto const phantomBase = parsedPayload(env, alice, withPhantom); + + STObject payload = phantomBase; + singleSign(payload, target.pk()); + STArray batchSigners(sfBatchSigners); + std::vector entries{bob, phantom}; + std::sort(entries.begin(), entries.end(), [](auto const& a, auto const& b) { + return a.id() < b.id(); + }); + for (auto const& acct : entries) + batchSigners.push_back(makeBatchSigner(acct)); + payload.setFieldArray(sfBatchSigners, batchSigners); + + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(findSigner(status, phantom.id())->satisfied); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + run() override + { + using namespace jtx; + auto const features = testableAmendments(); + testMalformedRequests(features); + testPendingUnsigned(features); + testExpiredStates(features); + testSingleSignAuthorization(features); + testMultiSignAuthorization(features); + testDelegateAuthorization(features); + testCounterpartyAuthorization(features); + testSponsorAuthorization(features); + testBatchAuthorization(features); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalRPC, rpc, xrpl); + +} // namespace xrpl::test diff --git a/src/xrpld/rpc/detail/Handler.cpp b/src/xrpld/rpc/detail/Handler.cpp index 4f5ce34c1fc..4036453dd2a 100644 --- a/src/xrpld/rpc/detail/Handler.cpp +++ b/src/xrpld/rpc/detail/Handler.cpp @@ -316,6 +316,10 @@ Handler const kHandlerArray[]{ .valueMethod = byRef(&doTransactionEntry), .role = Role::USER, .condition = Condition::NoCondition}, + {.name = "transaction_proposal", + .valueMethod = byRef(&doTransactionProposal), + .role = Role::USER, + .condition = Condition::NoCondition}, {.name = "tx", .valueMethod = byRef(&doTxJson), .role = Role::USER, diff --git a/src/xrpld/rpc/handlers/Handlers.h b/src/xrpld/rpc/handlers/Handlers.h index 7b347b2eccd..19006e218a2 100644 --- a/src/xrpld/rpc/handlers/Handlers.h +++ b/src/xrpld/rpc/handlers/Handlers.h @@ -129,6 +129,8 @@ doSubscribe(RPC::JsonContext&); json::Value doTransactionEntry(RPC::JsonContext&); json::Value +doTransactionProposal(RPC::JsonContext&); +json::Value doTxJson(RPC::JsonContext&); json::Value doTxHistory(RPC::JsonContext&); diff --git a/src/xrpld/rpc/handlers/TransactionProposal.cpp b/src/xrpld/rpc/handlers/TransactionProposal.cpp new file mode 100644 index 00000000000..1496aa973be --- /dev/null +++ b/src/xrpld/rpc/handlers/TransactionProposal.cpp @@ -0,0 +1,146 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +// "signed" is a C++ keyword, so it cannot be declared through the JSS macro. +static json::StaticString const kJssSigned{"signed"}; + +// A proposal is addressed either by its ledger-entry index (proposal_id) or +// by what that index is derived from: the proposed transaction's target +// account and TicketSequence. Field types, acceptance and error codes match +// ledger_entry's transaction_proposal addressing (parseTransactionProposal). +static std::expected +parseProposalID(json::Value const& params) +{ + bool const hasProposalID = params.isMember(jss::proposal_id); + bool const hasAccount = params.isMember(jss::account); + bool const hasTicketSeq = params.isMember(jss::ticket_seq); + + if (hasProposalID && !hasAccount && !hasTicketSeq) + { + return LedgerEntryHelpers::requiredUInt256(params, jss::proposal_id, "malformedRequest"); + } + + if (!hasProposalID && hasAccount && hasTicketSeq) + { + auto const target = + LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress"); + if (!target) + return std::unexpected(target.error()); + + auto const ticketSeq = + LedgerEntryHelpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest"); + if (!ticketSeq) + return std::unexpected(ticketSeq.error()); + + return keylet::txProposal(*target, *ticketSeq).key; + } + + return std::unexpected( + RPC::makeParamError("Specify either proposal_id or account with ticket_seq.")); +} + +static char const* +signerRoleLabel(proposal::SignerRole role) +{ + switch (role) + { + case proposal::SignerRole::account: + return "account"; + case proposal::SignerRole::batchParticipant: + return "batch_participant"; + case proposal::SignerRole::counterparty: + return "counterparty"; + case proposal::SignerRole::sponsor: + return "sponsor"; + } + return "unknown"; // LCOV_EXCL_LINE +} + +static char const* +proposalStateLabel(proposal::ProposalState state) +{ + switch (state) + { + case proposal::ProposalState::pending: + return "pending"; + case proposal::ProposalState::complete: + return "complete"; + case proposal::ProposalState::expired: + return "expired"; + } + return "unknown"; // LCOV_EXCL_LINE +} + +json::Value +doTransactionProposal(RPC::JsonContext& context) +{ + std::shared_ptr lpLedger; + auto jvResult = RPC::lookupLedger(lpLedger, context); + + if (!lpLedger) + return jvResult; + + uint256 uNodeIndex; + try + { + auto const parsed = parseProposalID(context.params); + if (!parsed) + return parsed.error(); + uNodeIndex = *parsed; + } + catch (json::Error const&) + { + // A wrongly-typed parameter (e.g. an array where a scalar belongs) + // is the caller's error, not an internal one. + return RPC::makeError(RpcInvalidParams); + } + + // The typed keylet makes an index that names a different ledger entry + // type read as absent rather than as a proposal. + auto const sleProposal = lpLedger->read(keylet::txProposal(uNodeIndex)); + if (!sleProposal) + { + RPC::injectError(RpcEntryNotFound, jvResult); + return jvResult; + } + + auto const status = proposal::evaluateProposal(*lpLedger, *sleProposal, context.j); + + jvResult[jss::proposal_id] = to_string(uNodeIndex); + jvResult[jss::proposal] = sleProposal->getJson(JsonOptions::Values::None); + jvResult[jss::proposal_status] = proposalStateLabel(status.state); + + json::Value& signers = (jvResult[jss::signing_status] = json::ValueType::Array); + for (auto const& signer : status.signers) + { + json::Value entry{json::ValueType::Object}; + entry[jss::account] = toBase58(signer.account); + entry[jss::role] = signerRoleLabel(signer.role); + entry[kJssSigned] = signer.satisfied; + if (signer.signedWeight) + entry[jss::signed_weight] = *signer.signedWeight; + if (signer.quorum) + entry[jss::quorum] = *signer.quorum; + signers.append(entry); + } + + return jvResult; +} + +} // namespace xrpl