Compare commits

...

6 Commits

Author SHA1 Message Date
Kassaking7
5b981dc7fb TransactionProposalCreate edge cases and tests (#7958) 2026-08-24 10:49:21 -04:00
Kassaking7
0810163743 Add Signerlist check and Sponsor for ProposalCreate (#8020) 2026-08-20 16:44:43 -04:00
Shawn Xie
9024c6e159 Refactor proposal test helpers (#7968) 2026-08-07 10:45:02 -04:00
Kassaking7
7f8c43285f Add TransactionProposalCreate for Cosign (#7887) 2026-08-05 12:19:25 -04:00
Kassaking7
86135323b8 Add TransactionProposal ledger object: format, hash index, and fields for Cosign (#7869) 2026-07-27 18:10:53 -04:00
Shawn Xie
3b7cfca750 add Cosign amendment (#7868) 2026-07-24 16:57:24 -04:00
25 changed files with 3001 additions and 3 deletions

View File

@@ -43,7 +43,14 @@ enum ApplyFlags : std::uint32_t {
// Transaction shouldn't be applied
// Signatures shouldn't be checked
TapDryRun = 0x1000
TapDryRun = 0x1000,
// Transaction is being preflighted as the payload of a
// TransactionProposalCreate. Its signatures are collected on-ledger
// afterward, so signature-presence checks (e.g. Batch signer matching)
// are skipped at proposal-creation time (On-Chain Cosigner spec
// §5.3.1.2).
TapProposal = 0x2000
};
constexpr ApplyFlags

View File

@@ -0,0 +1,87 @@
#pragma once
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/TxFormats.h>
#include <cstdint>
namespace xrpl::proposal {
/**
* Owner-reserve increments held by a proposal of an ordinary transaction.
*/
constexpr std::uint32_t kProposalOwnerCount = 5;
/**
* Owner-reserve increments held by a proposal of a Batch transaction. A
* proposed Batch stores up to eight inner transactions plus multi-account
* signatures, so it reserves more than an ordinary proposed transaction.
*/
constexpr std::uint32_t kBatchProposalOwnerCount = 10;
/**
* Owner-reserve increments held by a proposal of the given transaction.
*/
inline std::uint32_t
proposalOwnerCount(STObject const& proposedTx)
{
return proposedTx.getFieldU16(sfTransactionType) == ttBATCH ? kBatchProposalOwnerCount
: kProposalOwnerCount;
}
/**
* Whether the proposed transaction is itself a proposal transaction, which
* would nest one proposal inside another.
*
* TODO: cover ttTRANSACTION_PROPOSAL_SIGN and ttTRANSACTION_PROPOSAL_CANCEL
* once those transactions exist.
*/
inline bool
isProposalTx(STObject const& proposedTx)
{
return proposedTx.getFieldU16(sfTransactionType) == ttTRANSACTION_PROPOSAL_CREATE;
}
/**
* Whether the proposed transaction is independently submittable through the
* ordinary multi-sign path: not a nested proposal, not a pseudo-transaction,
* not itself flagged as someone else's inner batch transaction, and — if it
* is a Batch — none of its own inner transactions is a nested proposal or a
* pseudo-transaction either. A Batch inner transaction cannot itself be
* pseudo (preflight0 rejects the pseudo/tfInnerBatchTxn combination
* generically), but that guard lives outside this feature, so it is checked
* again here rather than relied upon.
*/
bool
isValidProposal(STObject const& proposedTx);
/**
* Whether the proposed transaction carries any signature field.
*
* A proposal is stored in unsigned canonical form; signatures may only ever
* arrive through TransactionProposalSign. Shared by the create-time check and
* the invariant that guards the stored entry, so the two cannot drift apart.
*/
inline bool
hasSignatureField(STObject const& proposedTx)
{
return proposedTx.isFieldPresent(sfTxnSignature) || proposedTx.isFieldPresent(sfSigners) ||
proposedTx.isFieldPresent(sfBatchSigners) ||
proposedTx.isFieldPresent(sfCounterpartySignature) ||
proposedTx.isFieldPresent(sfSponsorSignature);
}
/**
* Whether the proposed transaction's SigningPubKey is present and empty, as
* unsigned canonical form requires. An absent field is not the same as an
* empty one, and a populated one means the payload was already signed.
*/
inline bool
hasEmptySigningPubKey(STObject const& proposedTx)
{
return proposedTx.isFieldPresent(sfSigningPubKey) &&
proposedTx.getFieldVL(sfSigningPubKey).empty();
}
} // namespace xrpl::proposal

View File

@@ -187,6 +187,20 @@ check(uint256 const& key) noexcept
}
/** @} */
/**
* A TransactionProposal
*/
/** @{ */
Keylet
txProposal(AccountID const& target, std::uint32_t ticketSequence) noexcept;
inline Keylet
txProposal(uint256 const& key) noexcept
{
return {ltTRANSACTION_PROPOSAL, key};
}
/** @} */
/**
* A DepositPreauth
*/

View File

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

View File

@@ -636,5 +636,24 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({
{sfSponseeNode, SoeRequired},
}))
/** A ledger object holding a pending transaction proposal.
The proposed transaction is stored unsigned in ProposedTransaction, and
signatures accumulate on it over time. It becomes fully signed either from
a single signature by its own Account (or that account's Delegate), or once
the weight collected in its Signers field meets that account's quorum. At
that point the stored transaction is one that anyone may copy and submit.
\sa keylet::txProposal
*/
LEDGER_ENTRY(ltTRANSACTION_PROPOSAL, 0x0091, TransactionProposal, transaction_proposal, ({
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
{sfOwner, SoeRequired},
{sfProposedTransaction, SoeRequired},
{sfExpiration, SoeRequired},
{sfOwnerNode, SoeRequired},
}))
#undef EXPAND
#undef LEDGER_ENTRY_DUPLICATE

View File

@@ -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)
@@ -422,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

View File

@@ -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 <xrpl/tx/transactors/proposal/TransactionProposalCreate.h>
#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

View File

@@ -0,0 +1,242 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/LedgerEntryBase.h>
#include <xrpl/protocol_autogen/LedgerEntryBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
namespace xrpl::ledger_entries {
class TransactionProposalBuilder;
/**
* @brief Ledger Entry: TransactionProposal
*
* Type: ltTRANSACTION_PROPOSAL (0x0091)
* RPC Name: transaction_proposal
*
* Immutable wrapper around SLE providing type-safe field access.
* Use TransactionProposalBuilder to construct new ledger entries.
*/
class TransactionProposal : public LedgerEntryBase
{
public:
static constexpr LedgerEntryType entryType = ltTRANSACTION_PROPOSAL;
/**
* @brief Construct a TransactionProposal ledger entry wrapper from an existing SLE object.
* @throws std::runtime_error if the ledger entry type doesn't match.
*/
explicit TransactionProposal(SLE::const_pointer sle)
: LedgerEntryBase(std::move(sle))
{
// Verify ledger entry type
if (sle_->getType() != entryType)
{
throw std::runtime_error("Invalid ledger entry type for TransactionProposal");
}
}
// Ledger entry-specific field getters
/**
* @brief Get sfPreviousTxnID (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT256::type::value_type
getPreviousTxnID() const
{
return this->sle_->at(sfPreviousTxnID);
}
/**
* @brief Get sfPreviousTxnLgrSeq (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT32::type::value_type
getPreviousTxnLgrSeq() const
{
return this->sle_->at(sfPreviousTxnLgrSeq);
}
/**
* @brief Get sfOwner (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_ACCOUNT::type::value_type
getOwner() const
{
return this->sle_->at(sfOwner);
}
/**
* @brief Get sfProposedTransaction (SoeRequired)
* @note This is an untyped field (unknown).
* @return The field value.
*/
[[nodiscard]]
STObject
getProposedTransaction() const
{
return this->sle_->getFieldObject(sfProposedTransaction);
}
/**
* @brief Get sfExpiration (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT32::type::value_type
getExpiration() const
{
return this->sle_->at(sfExpiration);
}
/**
* @brief Get sfOwnerNode (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT64::type::value_type
getOwnerNode() const
{
return this->sle_->at(sfOwnerNode);
}
};
/**
* @brief Builder for TransactionProposal ledger entries.
*
* Provides a fluent interface for constructing ledger entries with method chaining.
* Uses STObject internally for flexible ledger entry construction.
* Inherits common field setters from LedgerEntryBuilderBase.
*/
class TransactionProposalBuilder : public LedgerEntryBuilderBase<TransactionProposalBuilder>
{
public:
/**
* @brief Construct a new TransactionProposalBuilder with required fields.
* @param previousTxnID The sfPreviousTxnID field value.
* @param previousTxnLgrSeq The sfPreviousTxnLgrSeq field value.
* @param owner The sfOwner field value.
* @param proposedTransaction The sfProposedTransaction field value.
* @param expiration The sfExpiration field value.
* @param ownerNode The sfOwnerNode field value.
*/
TransactionProposalBuilder(std::decay_t<typename SF_UINT256::type::value_type> const& previousTxnID,std::decay_t<typename SF_UINT32::type::value_type> const& previousTxnLgrSeq,std::decay_t<typename SF_ACCOUNT::type::value_type> const& owner,STObject const& proposedTransaction,std::decay_t<typename SF_UINT32::type::value_type> const& expiration,std::decay_t<typename SF_UINT64::type::value_type> const& ownerNode)
: LedgerEntryBuilderBase<TransactionProposalBuilder>(ltTRANSACTION_PROPOSAL)
{
setPreviousTxnID(previousTxnID);
setPreviousTxnLgrSeq(previousTxnLgrSeq);
setOwner(owner);
setProposedTransaction(proposedTransaction);
setExpiration(expiration);
setOwnerNode(ownerNode);
}
/**
* @brief Construct a TransactionProposalBuilder from an existing SLE object.
* @param sle The existing ledger entry to copy from.
* @throws std::runtime_error if the ledger entry type doesn't match.
*/
TransactionProposalBuilder(SLE::const_pointer sle)
{
if (sle->at(sfLedgerEntryType) != ltTRANSACTION_PROPOSAL)
{
throw std::runtime_error("Invalid ledger entry type for TransactionProposal");
}
object_ = *sle;
}
/**
* @brief Ledger entry-specific field setters
*/
/**
* @brief Set sfPreviousTxnID (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setPreviousTxnID(std::decay_t<typename SF_UINT256::type::value_type> const& value)
{
object_[sfPreviousTxnID] = value;
return *this;
}
/**
* @brief Set sfPreviousTxnLgrSeq (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setPreviousTxnLgrSeq(std::decay_t<typename SF_UINT32::type::value_type> const& value)
{
object_[sfPreviousTxnLgrSeq] = value;
return *this;
}
/**
* @brief Set sfOwner (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setOwner(std::decay_t<typename SF_ACCOUNT::type::value_type> const& value)
{
object_[sfOwner] = value;
return *this;
}
/**
* @brief Set sfProposedTransaction (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setProposedTransaction(STObject const& value)
{
object_.setFieldObject(sfProposedTransaction, value);
return *this;
}
/**
* @brief Set sfExpiration (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setExpiration(std::decay_t<typename SF_UINT32::type::value_type> const& value)
{
object_[sfExpiration] = value;
return *this;
}
/**
* @brief Set sfOwnerNode (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalBuilder&
setOwnerNode(std::decay_t<typename SF_UINT64::type::value_type> 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<SLE>(std::move(object_), index)};
}
};
} // namespace xrpl::ledger_entries

View File

@@ -0,0 +1,155 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/TransactionBase.h>
#include <xrpl/protocol_autogen/TransactionBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
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<STTx const> 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<TransactionProposalCreateBuilder>
{
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<typename SF_UINT32::type::value_type> const& expiration, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<TransactionProposalCreateBuilder>(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<STTx const> 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<typename SF_UINT32::type::value_type> 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<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -59,6 +59,10 @@ public:
, j(j)
{
XRPL_ASSERT((flags & TapBatch) == TapBatch, "Batch apply flag should be set");
XRPL_ASSERT_IF(
(flags & TapProposal) != TapNone,
(flags & TapDryRun) != TapNone,
"xrpl::PreflightContext : proposal preflight implies dry run");
}
PreflightContext(
@@ -70,6 +74,10 @@ public:
: registry(registry), tx(tx), rules(std::move(rules)), flags(flags), j(j)
{
XRPL_ASSERT((flags & TapBatch) == 0, "Batch apply flag should not be set");
XRPL_ASSERT_IF(
(flags & TapProposal) != TapNone,
(flags & TapDryRun) != TapNone,
"xrpl::PreflightContext : proposal preflight implies dry run");
}
PreflightContext&

View File

@@ -0,0 +1,44 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
namespace xrpl {
class TransactionProposalCreate : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TransactionProposalCreate(ApplyContext& ctx) : Transactor(ctx)
{
}
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override;
[[nodiscard]] bool
finalizeInvariants(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
};
} // namespace xrpl

View File

@@ -0,0 +1,39 @@
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
namespace xrpl::proposal {
bool
isValidProposal(STObject const& proposedTx)
{
if (isProposalTx(proposedTx))
return false;
if (isPseudoTx(proposedTx))
return false;
if (proposedTx.isFieldPresent(sfFlags) &&
(proposedTx.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)
return false;
if (proposedTx.getFieldU16(sfTransactionType) == ttBATCH &&
proposedTx.isFieldPresent(sfRawTransactions))
{
STArray const& innerTxns = proposedTx.getFieldArray(sfRawTransactions);
for (STObject const& inner : innerTxns)
{
if (isProposalTx(inner) || isPseudoTx(inner))
return false;
}
}
return true;
}
} // namespace xrpl::proposal

View File

@@ -5,6 +5,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/OracleHelpers.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -56,6 +57,7 @@ isReserveSponsorAllowed(TxType txType)
ttACCOUNT_SET,
ttREGULAR_KEY_SET,
ttSPONSORSHIP_TRANSFER,
ttTRANSACTION_PROPOSAL_CREATE,
};
return kReserveSponsorAllowed.contains(txType);
}
@@ -255,6 +257,8 @@ isLedgerEntryOwner(ReadView const& view, SLE const& sle, AccountID const& accoun
// to tecNO_PERMISSION.
return false;
}
case ltTRANSACTION_PROPOSAL:
return sle.getAccountID(sfOwner) == account;
default:
// LCOV_EXCL_START
UNREACHABLE("xrpl::isLedgerEntryOwner : object is not supported by sponsorship.");
@@ -278,6 +282,7 @@ isLedgerEntrySupportedBySponsorship(SLE const& sle)
case ltSIGNER_LIST:
case ltCREDENTIAL:
case ltRIPPLE_STATE:
case ltTRANSACTION_PROPOSAL:
return true;
default:
return false;
@@ -304,6 +309,11 @@ getLedgerEntryOwnerCount(SLE const& sle)
return 1;
return 2 + static_cast<std::uint32_t>(sle.getFieldArray(sfSignerEntries).size());
}
case ltTRANSACTION_PROPOSAL:
// Mirror TransactionProposalCreate's own reserve sizing so that
// creation and sponsorship accounting agree: a proposed Batch
// reserves more than an ordinary proposal.
return proposal::proposalOwnerCount(sle.getFieldObject(sfProposedTransaction));
case ltACCOUNT_ROOT:
// LCOV_EXCL_START
UNREACHABLE("AccountRoots are not supported by object sponsorship.");

View File

@@ -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
{

View File

@@ -324,6 +324,17 @@ preflight(
beast::Journal j)
{
PreflightContext const pfCtx(registry, tx, rules, flags, j);
// XRPL_ASSERT_IF in the PreflightContext constructor only fires in debug
// builds; re-check the same invariant here so a release build can't
// silently skip a proposed transaction's signature-presence checks
// outside of a dry run.
if ((flags & TapProposal) != TapNone && (flags & TapDryRun) == TapNone)
{
JLOG(j.fatal()) << "apply (preflight): TapProposal set without TapDryRun.";
return {pfCtx, {tefEXCEPTION, TxConsequences{tx}}};
}
try
{
return {pfCtx, invokePreflight(pfCtx)};
@@ -345,6 +356,14 @@ preflight(
beast::Journal j)
{
PreflightContext const pfCtx(registry, tx, parentBatchId, rules, flags, j);
// See the comment in the other preflight() overload above.
if ((flags & TapProposal) != TapNone && (flags & TapDryRun) == TapNone)
{
JLOG(j.fatal()) << "apply (preflight): TapProposal set without TapDryRun.";
return {pfCtx, {tefEXCEPTION, TxConsequences{tx}}};
}
try
{
return {pfCtx, invokePreflight(pfCtx)};

View File

@@ -5,6 +5,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
@@ -79,7 +80,10 @@ LoanSet::preflight(PreflightContext const& ctx)
return tx.getFieldObject(sfCounterpartySignature);
return std::nullopt;
}();
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig)
// A proposed LoanSet is stored unsigned; its CounterpartySignature is
// collected on-ledger afterward, so its absence here is expected, not an
// error (On-Chain Cosigner spec §5.3.1.2).
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && (ctx.flags & TapProposal) == 0)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature.";
return temBAD_SIGNER;

View File

@@ -0,0 +1,319 @@
#include <xrpl/tx/transactors/proposal/TransactionProposalCreate.h>
#include <xrpl/basics/Log.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DelegateHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/SignerEntries.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/applySteps.h>
#include <algorithm>
#include <cstdint>
#include <exception>
#include <expected>
#include <memory>
namespace xrpl {
NotTEC
TransactionProposalCreate::preflight(PreflightContext const& ctx)
{
if (ctx.tx[sfExpiration] == 0)
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: zero expiration.";
return temBAD_EXPIRATION;
}
STObject const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction);
// The proposed transaction must pass its own static checks under the
// current rules, so no statically-dead proposal can be stored. This also
// guarantees every field common to all transactions (TransactionType,
// Account, Fee, Sequence, ...) is present, since applyTemplate throws
// otherwise; the checks below can therefore read those fields directly
// without re-checking presence. TapDryRun 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 (On-Chain Cosigner spec §5.3.1.2). A
// proposedTx that fails here is rejected with its own type's preflight
// code (or temMALFORMED if it isn't even a valid instance of that type),
// ahead of the Cosigner-specific structural checks below.
try
{
STTx const stx{STObject{proposedTx}};
auto const inner =
xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun | TapProposal, ctx.j);
if (!isTesSuccess(inner.ter))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn "
"failed preflight: "
<< transHuman(inner.ter);
// Surface the proposed transaction type's own preflight code
// rather than collapsing it to a generic error (On-Chain Cosigner
// 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;
}
// The proposed transaction must be independently submittable through the
// ordinary multi-sign path: no nested proposals, no pseudo-transactions,
// no batch inner transactions — and, if it is a Batch, none of its own
// inner transactions may be a nested proposal or a pseudo-transaction
// either.
if (!proposal::isValidProposal(proposedTx))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is not "
"independently submittable.";
return temINVALID;
}
// The proposed transaction is stored in its unsigned canonical form; the
// ledger populates its signature fields as contributions arrive.
if (proposal::hasSignatureField(proposedTx))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn "
"carries signature fields.";
return temBAD_SIGNER;
}
if (!proposal::hasEmptySigningPubKey(proposedTx))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn "
"SigningPubKey must be present and empty.";
return temBAD_SIGNER;
}
// The proposed transaction must be ticket-based: it must carry a
// TicketSequence and must not use a live Sequence. Sequence is a required
// common field, so "no Sequence" is expressed as a Sequence of 0 rather
// than an absent field. A ticket decouples the proposal from the target
// account's live sequence, so unrelated target-account activity cannot
// invalidate it while signatures are collected (On-Chain Cosigner spec
// §4.2.1).
if (!proposedTx.isFieldPresent(sfTicketSequence) || proposedTx.getFieldU32(sfSequence) != 0)
return temSEQ_AND_TICKET;
// If this transaction itself is paying with a Ticket, and the proposed
// transaction is targeting that same account and Ticket, then applying
// this transaction consumes the very Ticket the proposal depends on
// before the proposal is even stored: the proposal would be dead on
// arrival, and its only recourse would be TransactionProposalCancel.
if (ctx.tx.getSeqProxy().isTicket() &&
proposedTx.getAccountID(sfAccount) == ctx.tx.getAccountID(sfAccount) &&
proposedTx.getFieldU32(sfTicketSequence) == ctx.tx.getSeqProxy().value())
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn "
"reuses the Ticket this transaction itself consumes.";
return temMALFORMED;
}
return tesSUCCESS;
}
TER
TransactionProposalCreate::preclaim(PreclaimContext const& ctx)
{
if (hasExpired(ctx.view, ctx.tx[~sfExpiration]))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: already expired.";
return tecEXPIRED;
}
auto const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction);
// Once the proposed transaction's own ledger bound has passed it can never
// be applied, so the proposal is dead on arrival. The bound is the one the
// ordinary path uses for tefMAX_LEDGER: the last ledger in which the
// proposed transaction may still be submitted (On-Chain Cosigner spec
// §4.5).
if (proposedTx.isFieldPresent(sfLastLedgerSequence) &&
proposedTx.getFieldU32(sfLastLedgerSequence) <= ctx.view.seq())
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn "
"LastLedgerSequence has passed.";
return tecEXPIRED;
}
AccountID const target = proposedTx.getAccountID(sfAccount);
auto const sleTarget = ctx.view.read(keylet::account(target));
if (!sleTarget)
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: target account "
"does not exist.";
return tecNO_TARGET;
}
// A pseudo-account cannot authorize a transaction through a SignerList.
if (isPseudoAccount(sleTarget))
return tecNO_PERMISSION;
// Only the target account itself, an account on its SignerList, or (if
// the proposed transaction's own type has been delegated by the target,
// Permission Delegation / XLS-75) that delegate or an account on the
// delegate's own SignerList, may create a proposal against it. Otherwise
// any account could spam or squat the target's Tickets with unwanted
// proposals (On-Chain Cosigner V1 scope).
if (AccountID const proposer = ctx.tx.getAccountID(sfAccount); proposer != target)
{
// Whether `proposer` is `account` itself or an entry on `account`'s
// applicable SignerList.
auto isAuthorizedFor = [&](AccountID const& account) -> std::expected<bool, TER> {
if (proposer == account)
return true;
auto const sleSigners = ctx.view.read(keylet::signerList(account));
if (!sleSigners)
return false;
auto const accountSigners = SignerEntries::deserialize(*sleSigners, ctx.j, "ledger");
if (!accountSigners)
return std::unexpected(TER{accountSigners.error()});
return std::ranges::any_of(
*accountSigners, [&](auto const& entry) { return entry.account == proposer; });
};
auto isSigner = isAuthorizedFor(target);
if (!isSigner)
return isSigner.error();
// A delegate that the target has granted permission over the
// proposed transaction's own type — or one of that delegate's own
// signers — is equally authorized: it will need to help complete
// the proposed transaction's own authorization anyway once the
// proposal is submitted.
if (!*isSigner && proposedTx.isFieldPresent(sfDelegate))
{
AccountID const delegateAccount = proposedTx.getAccountID(sfDelegate);
// NOLINTNEXTLINE(readability-suspicious-call-argument)
auto const sleDelegate = ctx.view.read(keylet::delegate(target, delegateAccount));
if (sleDelegate &&
isTesSuccess(checkTxPermission(sleDelegate, STTx{STObject{proposedTx}})))
{
isSigner = isAuthorizedFor(delegateAccount);
if (!isSigner)
return isSigner.error();
}
}
if (!*isSigner)
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposer is "
"not the target account, one of its "
"signers, or an authorized delegate.";
return tecNO_PERMISSION;
}
}
std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence);
// The proposal reserves the ticket for as long as it exists (On-Chain
// Cosigner spec §4.2.1, §5.3.2): a ticket that doesn't exist yet can't be
// reserved.
if (!ctx.view.exists(keylet::ticket(target, ticketSequence)))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: target ticket "
"does not exist.";
return tefNO_TICKET;
}
if (ctx.view.exists(keylet::txProposal(target, ticketSequence)))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: duplicate proposal.";
return tecDUPLICATE;
}
return tesSUCCESS;
}
TER
TransactionProposalCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const proposedTx = ctx_.tx.getFieldObject(sfProposedTransaction);
std::uint32_t const ownerCount = proposal::proposalOwnerCount(proposedTx);
// The proposal holds a full transaction plus its collected signatures, so
// it reserves more than a typical ledger entry (5 increments; 10 for a
// proposed Batch).
if (auto const ret = checkReserve(
ctx_.getApplyViewContext(),
sle,
preFeeBalance_,
{.ownerCountDelta = static_cast<int>(ownerCount)},
ctx_.journal);
!isTesSuccess(ret))
return ret;
AccountID const target = proposedTx.getAccountID(sfAccount);
std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence);
Keylet const proposalKeylet = keylet::txProposal(target, ticketSequence);
auto sleProposal = std::make_shared<SLE>(proposalKeylet);
sleProposal->setAccountID(sfOwner, accountID_);
sleProposal->setFieldObject(sfProposedTransaction, proposedTx);
sleProposal->setFieldU32(sfExpiration, ctx_.tx[sfExpiration]);
view().insert(sleProposal);
auto viewJ = ctx_.registry.get().getJournal("View");
{
auto const page = view().dirInsert(
keylet::ownerDir(accountID_), proposalKeylet, describeOwnerDir(accountID_));
if (!page)
return tecDIR_FULL; // LCOV_EXCL_LINE
sleProposal->setFieldU64(sfOwnerNode, *page);
}
increaseOwnerCount(ctx_.getApplyViewContext(), sle, ownerCount, viewJ);
addSponsorToLedgerEntry(ctx_.getApplyViewContext(), sleProposal);
return tesSUCCESS;
}
void
TransactionProposalCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
{
// No transaction-specific invariants yet (future work). Object-level
// invariants for the TransactionProposal ledger entry (unsigned canonical
// form, non-zero Expiration, correct ProposalID key, sorted/unique signer
// arrays) belong in a protocol-level ValidTransactionProposal check.
}
bool
TransactionProposalCreate::finalizeInvariants(
STTx const&,
TER,
XRPAmount,
ReadView const&,
beast::Journal const&)
{
// No transaction-specific invariants yet (future work).
return true;
}
} // namespace xrpl

View File

@@ -199,7 +199,11 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx)
bool const isAccountReserveSponsorship =
isCreateOrReassign && reserveSponsor && !ctx.tx.isFieldPresent(sfObjectID);
if (isAccountReserveSponsorship && !ctx.tx.isFieldPresent(sfSponsorSignature))
// A proposed SponsorshipTransfer is stored unsigned; its SponsorSignature
// is collected on-ledger afterward, so its absence here is expected, not
// an error (On-Chain Cosigner spec §5.3.1.2).
if (isAccountReserveSponsorship && !ctx.tx.isFieldPresent(sfSponsorSignature) &&
(ctx.flags & TapProposal) == 0)
{
JLOG(ctx.j.debug()) << "preflight: account sponsorship requires sfSponsorSignature";
return temMALFORMED;

View File

@@ -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 (On-Chain Cosigner spec §5.3.1.2).
if ((ctx.flags & TapProposal) != 0)
return tesSUCCESS;
auto const parentBatchId = ctx.tx.getTransactionID();
auto const outerAccount = ctx.tx.getAccountID(sfAccount);
// Accounts that must sign the batch: each inner authorizer and counterparty

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
#include <test/jtx/proposal.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/batch.h>
#include <test/jtx/multisign.h>
#include <test/jtx/ticket.h>
#include <test/jtx/utility.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/jss.h>
#include <cstdint>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::test::jtx::proposal {
json::Value
create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration)
{
json::Value jv;
jv[jss::TransactionType] = "TransactionProposalCreate";
jv[jss::Account] = proposer.human();
jv[sfProposedTransaction.jsonName] = proposedTx;
jv[sfExpiration.jsonName] = expiration;
return jv;
}
json::Value
unsignedPayload(Env const& env, json::Value tx, std::uint32_t ticketSeq)
{
// Unsigned canonical form: an empty SigningPubKey and no signature fields
// at all. Signatures may only ever arrive through TransactionProposalSign.
tx[jss::SigningPubKey] = "";
// Ticket-based rather than sequence-based. Sequence is a required common
// field, so "no Sequence" is expressed as a Sequence of 0.
tx[jss::Sequence] = 0;
tx[sfTicketSequence.jsonName] = ticketSeq;
// The target account pays this fee when the completed transaction is
// submitted, so it is fixed now. A fee already chosen by the caller stands.
fillFee(tx, *env.current());
return tx;
}
json::Value
innerTx(json::Value tx, std::uint32_t seq)
{
return batch::Inner{std::move(tx), seq}.getTxn();
}
json::Value
unsignedBatch(
Env const& env,
Account const& target,
std::uint32_t ticketSeq,
std::uint32_t flags,
std::vector<json::Value> const& inners,
std::optional<std::uint32_t> numSigners)
{
// Each inner account other than the outer one will contribute one
// BatchSigners entry once the signatures are collected, and the outer fee
// has to cover them from the start (Batch::calculateBaseFee).
std::uint32_t const signers = numSigners ? *numSigners : [&]() {
std::set<std::string> participants;
for (auto const& inner : inners)
{
if (auto const account = inner[jss::Account].asString(); account != target.human())
participants.insert(account);
}
return static_cast<std::uint32_t>(participants.size());
}();
json::Value jv = batch::outer(
target,
0,
batch::calcBatchFee(env, signers, static_cast<std::uint32_t>(inners.size())),
flags);
json::Value& rawTransactions = jv[jss::RawTransactions];
for (auto const& inner : inners)
rawTransactions[rawTransactions.size()][jss::RawTransaction] = inner;
return unsignedPayload(env, std::move(jv), ticketSeq);
}
void
authorizeProposer(Env& env, Account const& target, Account const& proposer)
{
env(signers(target, 1, {{proposer, 1}}));
env.close();
}
std::uint32_t
createTicket(Env& env, Account const& account, std::uint32_t count)
{
// The tickets a TicketCreate makes are numbered from the sequence that
// follows the one it consumes.
std::uint32_t const firstTicketSeq = env.seq(account) + 1;
env(ticket::create(account, count));
env.close();
return firstTicketSeq;
}
std::uint32_t
expiration(Env& env, NetClock::duration delta)
{
return (env.now() + delta).time_since_epoch().count();
}
SLE::const_pointer
entry(Env const& env, AccountID const& target, std::uint32_t ticketSeq)
{
return env.le(keylet::txProposal(target, ticketSeq));
}
SLE::const_pointer
entry(Env const& env, Account const& target, std::uint32_t ticketSeq)
{
return entry(env, target.id(), ticketSeq);
}
} // namespace xrpl::test::jtx::proposal

150
src/test/jtx/proposal.h Normal file
View File

@@ -0,0 +1,150 @@
#pragma once
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <cstdint>
#include <optional>
#include <vector>
/**
* @brief Helpers for constructing TransactionProposal test transactions.
*/
namespace xrpl::test::jtx::proposal {
// The owner-reserve increments a proposal holds against its proposer. Tests
// spend these rather than repeating their values, so they follow the transactor
// instead of checking it; TransactionProposalCreate_test pins the values
// themselves, so a change to them has to be a deliberate one.
using xrpl::proposal::kBatchProposalOwnerCount;
using xrpl::proposal::kProposalOwnerCount;
/**
* @brief Build a TransactionProposalCreate carrying an unsigned proposed
* transaction.
*
* @param proposer The account creating and paying the reserve for the proposal.
* @param proposedTx The proposed transaction, in unsigned canonical form; see
* unsignedPayload().
* @param expiration Absolute time after which the proposal may no longer be
* completed.
* @return The TransactionProposalCreate JSON object.
*/
json::Value
create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration);
/**
* @brief Put a transaction of any type into the form a proposal stores it in:
* unsigned and ticket-based, with the fee the target account will pay fixed
* now.
*
* This takes whatever any jtx generator produces — @c pay(), @c loan::set(),
* @c sponsor::transfer(), an outer @c Batch — and applies only what the
* proposal itself demands of the payload, so a test never hand-rolls those
* fields. It leaves the rest of @p tx untouched, which is what lets a test
* build one valid payload and then break exactly one rule of it.
*
* The payload is ticket-based so unrelated activity on the target account
* cannot invalidate it while signatures are collected. A Fee already set on
* @p tx is left alone, so a payload whose fee is not the base fee — a Batch,
* say — can carry its own; otherwise the fee is filled in the same way as for
* an ordinary submission.
*
* @param env The test environment providing ledger fee settings.
* @param tx The transaction to propose.
* @param ticketSeq A ticket sequence owned by @p tx's account.
* @return The proposed transaction JSON object.
*/
json::Value
unsignedPayload(Env const& env, json::Value tx, std::uint32_t ticketSeq);
/**
* @brief Put a transaction into the form an inner transaction of a proposed
* Batch takes, as @c batch::Inner does for an ordinary Batch.
*
* @param tx The transaction to nest.
* @param seq The sequence number of @p tx's own account.
* @return The inner transaction JSON object.
*/
json::Value
innerTx(json::Value tx, std::uint32_t seq);
/**
* @brief An unsigned outer Batch payload holding @p inners.
*
* A proposed Batch stores no BatchSigners — the participants' signatures are
* collected on-ledger afterwards — but its fee is fixed now and must already
* cover them. By default one signer is assumed for each inner account other
* than @p target, which is what those participants will contribute.
*
* @param env The test environment providing ledger fee settings.
* @param target The outer account of the Batch, and the proposal's target.
* @param ticketSeq A ticket sequence owned by @p target.
* @param flags The Batch mode flags, e.g. @c tfAllOrNothing.
* @param inners The inner transactions; see innerTx().
* @param numSigners Overrides the number of signatures the fee accounts for.
* @return The proposed Batch JSON object.
*/
json::Value
unsignedBatch(
Env const& env,
Account const& target,
std::uint32_t ticketSeq,
std::uint32_t flags,
std::vector<json::Value> const& inners,
std::optional<std::uint32_t> numSigners = std::nullopt);
/**
* @brief Give @p proposer a place on @p target's SignerList, so it may
* create proposals against @p target.
*
* Only the target account itself, or an account on its SignerList, may
* create a TransactionProposalCreate against it (On-Chain Cosigner V1
* authorization). Sets a minimal one-signer, quorum-1 list; the quorum
* itself is irrelevant here since it only governs the proposed
* transaction's own completion, not who may propose it.
*
* @param env The test environment.
* @param target The account whose SignerList is set.
* @param proposer The account to add to it.
*/
void
authorizeProposer(Env& env, Account const& target, Account const& proposer);
/**
* @brief Create tickets for a proposal to be built against, and close the
* ledger.
*
* @param env The test environment.
* @param account The account that will own the tickets, i.e. the target of the
* proposals to come.
* @param count How many tickets to create.
* @return The first ticket sequence created; the rest follow it.
*/
std::uint32_t
createTicket(Env& env, Account const& account, std::uint32_t count = 1);
/**
* @brief An absolute expiration @p delta past the environment's current time.
*
* @c expiration(env, 0s) is an expiration that has already passed.
*/
std::uint32_t
expiration(Env& env, NetClock::duration delta);
/**
* @brief The proposal stored against a target account's ticket.
* @return empty if no such proposal exists.
*/
[[nodiscard]] SLE::const_pointer
entry(Env const& env, AccountID const& target, std::uint32_t ticketSeq);
[[nodiscard]] SLE::const_pointer
entry(Env const& env, Account const& target, std::uint32_t ticketSeq);
} // namespace xrpl::test::jtx::proposal

View File

@@ -0,0 +1,223 @@
// Auto-generated unit tests for ledger entry TransactionProposal
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol_autogen/ledger_entries/TransactionProposal.h>
#include <xrpl/protocol_autogen/ledger_entries/Ticket.h>
#include <string>
namespace xrpl::ledger_entries {
// 1 & 4) Set fields via builder setters, build, then read them back via
// wrapper getters. After build(), validate() should succeed for both the
// builder's STObject and the wrapper's SLE.
TEST(TransactionProposalTests, BuilderSettersRoundTrip)
{
uint256 const index{1u};
auto const previousTxnIDValue = canonical_UINT256();
auto const previousTxnLgrSeqValue = canonical_UINT32();
auto const ownerValue = canonical_ACCOUNT();
auto const proposedTransactionValue = canonical_OBJECT();
auto const expirationValue = canonical_UINT32();
auto const ownerNodeValue = canonical_UINT64();
TransactionProposalBuilder builder{
previousTxnIDValue,
previousTxnLgrSeqValue,
ownerValue,
proposedTransactionValue,
expirationValue,
ownerNodeValue
};
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
EXPECT_TRUE(builder.validate());
auto const entry = builder.build(index);
EXPECT_TRUE(entry.validate());
{
auto const& expected = previousTxnIDValue;
auto const actual = entry.getPreviousTxnID();
expectEqualField(expected, actual, "sfPreviousTxnID");
}
{
auto const& expected = previousTxnLgrSeqValue;
auto const actual = entry.getPreviousTxnLgrSeq();
expectEqualField(expected, actual, "sfPreviousTxnLgrSeq");
}
{
auto const& expected = ownerValue;
auto const actual = entry.getOwner();
expectEqualField(expected, actual, "sfOwner");
}
{
auto const& expected = proposedTransactionValue;
auto const actual = entry.getProposedTransaction();
expectEqualField(expected, actual, "sfProposedTransaction");
}
{
auto const& expected = expirationValue;
auto const actual = entry.getExpiration();
expectEqualField(expected, actual, "sfExpiration");
}
{
auto const& expected = ownerNodeValue;
auto const actual = entry.getOwnerNode();
expectEqualField(expected, actual, "sfOwnerNode");
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
EXPECT_EQ(*ledgerIndex, index);
EXPECT_EQ(entry.getKey(), index);
}
// 2 & 4) Start from an SLE, set fields directly on it, construct a builder
// from that SLE, build a new wrapper, and verify all fields (and validate()).
TEST(TransactionProposalTests, BuilderFromSleRoundTrip)
{
uint256 const index{2u};
auto const previousTxnIDValue = canonical_UINT256();
auto const previousTxnLgrSeqValue = canonical_UINT32();
auto const ownerValue = canonical_ACCOUNT();
auto const proposedTransactionValue = canonical_OBJECT();
auto const expirationValue = canonical_UINT32();
auto const ownerNodeValue = canonical_UINT64();
auto sle = std::make_shared<SLE>(TransactionProposal::entryType, index);
sle->at(sfPreviousTxnID) = previousTxnIDValue;
sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue;
sle->at(sfOwner) = ownerValue;
sle->setFieldObject(sfProposedTransaction, proposedTransactionValue);
sle->at(sfExpiration) = expirationValue;
sle->at(sfOwnerNode) = ownerNodeValue;
TransactionProposalBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
auto const entryFromBuilder = builderFromSle.build(index);
TransactionProposal entryFromSle{sle};
EXPECT_TRUE(entryFromBuilder.validate());
EXPECT_TRUE(entryFromSle.validate());
{
auto const& expected = previousTxnIDValue;
auto const fromSle = entryFromSle.getPreviousTxnID();
auto const fromBuilder = entryFromBuilder.getPreviousTxnID();
expectEqualField(expected, fromSle, "sfPreviousTxnID");
expectEqualField(expected, fromBuilder, "sfPreviousTxnID");
}
{
auto const& expected = previousTxnLgrSeqValue;
auto const fromSle = entryFromSle.getPreviousTxnLgrSeq();
auto const fromBuilder = entryFromBuilder.getPreviousTxnLgrSeq();
expectEqualField(expected, fromSle, "sfPreviousTxnLgrSeq");
expectEqualField(expected, fromBuilder, "sfPreviousTxnLgrSeq");
}
{
auto const& expected = ownerValue;
auto const fromSle = entryFromSle.getOwner();
auto const fromBuilder = entryFromBuilder.getOwner();
expectEqualField(expected, fromSle, "sfOwner");
expectEqualField(expected, fromBuilder, "sfOwner");
}
{
auto const& expected = proposedTransactionValue;
auto const fromSle = entryFromSle.getProposedTransaction();
auto const fromBuilder = entryFromBuilder.getProposedTransaction();
expectEqualField(expected, fromSle, "sfProposedTransaction");
expectEqualField(expected, fromBuilder, "sfProposedTransaction");
}
{
auto const& expected = expirationValue;
auto const fromSle = entryFromSle.getExpiration();
auto const fromBuilder = entryFromBuilder.getExpiration();
expectEqualField(expected, fromSle, "sfExpiration");
expectEqualField(expected, fromBuilder, "sfExpiration");
}
{
auto const& expected = ownerNodeValue;
auto const fromSle = entryFromSle.getOwnerNode();
auto const fromBuilder = entryFromBuilder.getOwnerNode();
expectEqualField(expected, fromSle, "sfOwnerNode");
expectEqualField(expected, fromBuilder, "sfOwnerNode");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
// 3) Verify wrapper throws when constructed from wrong ledger entry type.
TEST(TransactionProposalTests, WrapperThrowsOnWrongEntryType)
{
uint256 const index{3u};
// Build a valid ledger entry of a different type
// Ticket requires: Account, OwnerNode, TicketSequence, PreviousTxnID, PreviousTxnLgrSeq
// Check requires: Account, Destination, SendMax, Sequence, OwnerNode, DestinationNode, PreviousTxnID, PreviousTxnLgrSeq
TicketBuilder wrongBuilder{
canonical_ACCOUNT(),
canonical_UINT64(),
canonical_UINT32(),
canonical_UINT256(),
canonical_UINT32()};
auto wrongEntry = wrongBuilder.build(index);
EXPECT_THROW(TransactionProposal{wrongEntry.getSle()}, std::runtime_error);
}
// 4) Verify builder throws when constructed from wrong ledger entry type.
TEST(TransactionProposalTests, BuilderThrowsOnWrongEntryType)
{
uint256 const index{4u};
// Build a valid ledger entry of a different type
TicketBuilder wrongBuilder{
canonical_ACCOUNT(),
canonical_UINT64(),
canonical_UINT32(),
canonical_UINT256(),
canonical_UINT32()};
auto wrongEntry = wrongBuilder.build(index);
EXPECT_THROW(TransactionProposalBuilder{wrongEntry.getSle()}, std::runtime_error);
}
}

View File

@@ -0,0 +1,162 @@
// Auto-generated unit tests for transaction TransactionProposalCreate
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/TransactionProposalCreate.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
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);
}
}

View File

@@ -741,6 +741,30 @@ parseSponsorship(
return keylet::sponsorship(*sponsorID, *sponseeID).key;
}
static std::expected<uint256, json::Value>
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<uint256, json::Value>
parseTicket(
json::Value const& params,