feat: Add TransactionProposalCancel & Cosign Ticket reservation (#8538)

Co-authored-by: Shawn Xie <[email protected]>
Co-authored-by: Kassaking7 <[email protected]>
Co-authored-by: Shawn Xie <[email protected]>
Co-authored-by: Zhiyuan Wang <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 19:07:45 +00:00
1 parent b1f9dc6919
commit 33e5a6fa7c
18 files changed
+3987 -42

No files matched your search

+108 -21
View File
@@ -1,10 +1,17 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.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/TxFormats.h>
#include <cstdint>
#include <optional>
namespace xrpl::proposal {
@@ -30,31 +37,22 @@ proposalOwnerCount(STObject const& proposedTx)
: 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.
* ordinary multi-sign path: not a proposal transaction itself, not a
* pseudo-transaction, not itself flagged as someone else's inner batch
* transaction, and — if it is a Batch — every one of its own inner
* transactions carries a TransactionType and is neither a proposal, a
* pseudo-transaction, nor a nested Batch. A Batch inner transaction cannot
* lack a TransactionType, be pseudo, or be a nested Batch (STTx construction
* rejects a typeless inner and an inner Batch, and preflight0 rejects the
* pseudo/tfInnerBatchTxn combination), but those guards live outside this
* feature, so they are checked again here rather than relied upon.
*
* TODO: cover ttTRANSACTION_PROPOSAL_SIGN once that transaction exists.
*/
bool
isValidProposal(STObject const& proposedTx);
isValidProposalTxnType(STObject const& proposedTx);
/**
* Whether the proposed transaction carries any signature field.
@@ -84,4 +82,93 @@ hasEmptySigningPubKey(STObject const& proposedTx)
proposedTx.getFieldVL(sfSigningPubKey).empty();
}
/**
* Whether the proposal is terminal (On-Chain Cosigner spec §4.5). A terminal proposal
* stops accepting signatures and anyone may delete it. An already signed
* payload can still execute after Expiration, because the Ticket reservation
* compares payloads only; a passed LastLedgerSequence does stop it.
*
* A proposal is terminal when either:
* - its Expiration has passed (the parent ledger closed at or after it), or
* - the proposed transaction carries a LastLedgerSequence and the current
* ledger sequence is strictly greater than it. This mirrors the ordinary
* transactor's tefMAX_LEDGER rule: at view.seq() == LastLedgerSequence the
* payload can still be submitted in the current ledger, so the proposal is
* still live.
*
* @param view The ledger the deciding transaction is being applied to.
* @param expiration The proposal's Expiration field.
* @param proposedTx The proposal's ProposedTransaction field.
*/
bool
isTerminal(
ReadView const& view,
std::optional<std::uint32_t> expiration,
STObject const& proposedTx);
/**
* 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 (On-Chain Cosigner spec §4.5): automatic cleanup when the
* proposed transaction's TicketSequence is consumed, and the
* TransactionProposalCancel / TransactionProposalSign cleanup paths once
* those transactions exist.
*
* The release goes through decreaseOwnerCountForObject, so it follows the
* sfSponsor an entry created with reserve sponsorship carries: the reserve is
* returned to whoever was holding it, the Owner or that sponsor.
*
* @param view The apply view for making changes
* @param sleProposal The TransactionProposal ledger entry to delete
* @param j Journal for logging
* @return tesSUCCESS, tecINTERNAL for an invalid entry, or tefBAD_LEDGER if
* the ledger contradicts the entry
*/
TER
deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j);
/**
* Whether tx carries the same payload as a proposal's stored
* ProposedTransaction (On-Chain Cosigner spec §4.2.1).
*
* The payload is the signing payload: the fields the collected signatures
* commit to. Compared is every field except those flagged kNotSigning
* (TxnSignature, Signers, BatchSigners, CounterpartySignature,
* SponsorSignature, Signature, MasterSignature) and SigningPubKey, which is
* stored empty and filled only if the target signs with its own key. So the
* completed transaction matches no matter which mix of collected or
* off-ledger signatures it carries, and any change to a non-signature field
* results in a false-return value.
*
* Signature (PaymentChannelClaim and the XChain attestation transactions)
* is deliberately not compared: it is outside the account's own TxnSignature
* even for an ordinary submission, so a proposal cannot bind it more tightly
* than the signatures it collects do, and those transactors still validate a
* substituted value exactly as they would for an ordinary submission.
* MasterSignature appears in no transaction format.
*
* @param proposedTx The proposal's ProposedTransaction field.
* @param tx The transaction to compare, typically an STTx.
*/
bool
payloadMatches(STObject const& proposedTx, STObject const& tx);
/**
* Whether tx may consume its Ticket.
*
* While a proposal keyed to the Ticket exists, the target's Ticket is
* reserved: only the proposal's own proposed transaction may spend it
* (On-Chain Cosigner spec §4.2.1), so that unrelated target-account activity cannot
* invalidate the proposal while signatures are being collected.
* TransactionProposalCancel is not allowed through: it deletes the
* reservation without consuming the ticket.
*
* @param view The ledger containing the Ticket and any reservation.
* @param tx The transaction attempting to consume that ticket.
*/
bool
canConsumeTicket(ReadView const& view, STTx const& tx);
} // namespace xrpl::proposal
+2
View File
@@ -231,6 +231,8 @@ enum TERcodes : TERUnderlyingType {
terNO_DELEGATE_PERMISSION, // Delegate does not have permission
terLOCKED, // MPT is locked
terNO_PERMISSION, // No permission but retry
terTICKET_RESERVED, // Ticket is reserved and cannot be consumed
// until the reservation is released
};
//------------------------------------------------------------------------------
@@ -1175,6 +1175,20 @@ TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 95, TransactionProposalCreate,
{sfExpiration, SoeRequired},
}))
// ttTRANSACTION_PROPOSAL_SIGN arrives in a later PR and takes the next unassigned type.
/** This transaction deletes a TransactionProposal, releasing its owner's
reserve. While the proposal is live only its Owner or its target account
may cancel; once it is terminal anyone may (On-Chain Cosigner spec §7). */
#if TRANSACTION_INCLUDE
# include <xrpl/tx/transactors/proposal/TransactionProposalCancel.h>
#endif
TRANSACTION(ttTRANSACTION_PROPOSAL_CANCEL, 94, TransactionProposalCancel,
({.amendment = featureCosign}),
({
{sfProposalID, SoeRequired},
}))
/** This system-generated transaction type is used to update the status of the various amendments.
For details, see: https://xrpl.org/amendments.html
@@ -0,0 +1,131 @@
// 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 TransactionProposalCancelBuilder;
/**
* @brief Transaction: TransactionProposalCancel
*
* Type: ttTRANSACTION_PROPOSAL_CANCEL (94)
* Delegable: Delegation::NotDelegable
* Amendment: featureCosign
* Privileges: Privilege::NoPriv
*
* Immutable wrapper around STTx providing type-safe field access.
* Use TransactionProposalCancelBuilder to construct new transactions.
*/
class TransactionProposalCancel : public TransactionBase
{
public:
static constexpr xrpl::TxType txType = ttTRANSACTION_PROPOSAL_CANCEL;
/**
* @brief Construct a TransactionProposalCancel transaction wrapper from an existing STTx object.
* @throws std::runtime_error if the transaction type doesn't match.
*/
explicit TransactionProposalCancel(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 TransactionProposalCancel");
}
}
// Transaction-specific field getters
/**
* @brief Get sfProposalID (SoeRequired)
* @return The field value.
*/
[[nodiscard]]
SF_UINT256::type::value_type
getProposalID() const
{
return this->tx_->at(sfProposalID);
}
};
/**
* @brief Builder for TransactionProposalCancel 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 TransactionProposalCancelBuilder : public TransactionBuilderBase<TransactionProposalCancelBuilder>
{
public:
/**
* @brief Construct a new TransactionProposalCancelBuilder with required fields.
* @param account The account initiating the transaction.
* @param proposalID The sfProposalID field value.
* @param sequence Optional sequence number for the transaction.
* @param fee Optional fee for the transaction.
*/
TransactionProposalCancelBuilder(SF_ACCOUNT::type::value_type account,
std::decay_t<typename SF_UINT256::type::value_type> const& proposalID, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<TransactionProposalCancelBuilder>(ttTRANSACTION_PROPOSAL_CANCEL, account, sequence, fee)
{
setProposalID(proposalID);
}
/**
* @brief Construct a TransactionProposalCancelBuilder from an existing STTx object.
* @param tx The existing transaction to copy from.
* @throws std::runtime_error if the transaction type doesn't match.
*/
TransactionProposalCancelBuilder(std::shared_ptr<STTx const> tx)
{
if (tx->getTxnType() != ttTRANSACTION_PROPOSAL_CANCEL)
{
throw std::runtime_error("Invalid transaction type for TransactionProposalCancelBuilder");
}
object_ = *tx;
}
/**
* @brief Transaction-specific field setters
*/
/**
* @brief Set sfProposalID (SoeRequired)
* @return Reference to this builder for method chaining.
*/
TransactionProposalCancelBuilder&
setProposalID(std::decay_t<typename SF_UINT256::type::value_type> const& value)
{
object_[sfProposalID] = value;
return *this;
}
/**
* @brief Build and return the TransactionProposalCancel wrapper.
* @param publicKey The public key for signing.
* @param secretKey The secret key for signing.
* @return The constructed transaction wrapper.
*/
TransactionProposalCancel
build(PublicKey const& publicKey, SecretKey const& secretKey)
{
sign(publicKey, secretKey);
return TransactionProposalCancel{std::make_shared<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions
+16
View File
@@ -375,6 +375,22 @@ public:
/////////////////////////////////////////////////////
// Interface used by AccountDelete
/**
* This method removes one Ticket ledger-object from the ledger. AccountDelete uses it to
* clean up Ticket objects en masse.
*
* This method reduces the TicketCount and the OwnerCount of the Owner AccountRoot object. If
* a TransactionProposal reserved this Ticket, that proposal is deleted as well: once the
* Ticket is gone, its proposed transaction can never be applied (tefNO_TICKET).
*
* @param view the apply view for making changes.
* @param account the account which owns the Ticket object.
* @param ticketIndex ticketIndex is used to construct the ledger-entry ID of Ticket object.
* @param j the journal reference used to log messages.
*
* @return the final TER after all invariant checks. `tefBAD_LEDGER` is returned if Ticket,
* Owner AccountRoot is not found in the apply-view ledger.
*/
static TER
ticketDelete(
ApplyView& view,
@@ -0,0 +1,44 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.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 TransactionProposalCancel : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit TransactionProposalCancel(ApplyContext& ctx) : Transactor(ctx)
{
}
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after) override;
[[nodiscard]] bool
finalizeInvariants(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
};
} // namespace xrpl
+157 -5
View File
@@ -1,18 +1,51 @@
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STArray.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <optional>
namespace xrpl::proposal {
namespace {
// Proposals do not nest, so a proposed transaction may carry none of these
// types, neither as its own payload nor as a Batch inner transaction.
constexpr auto kProposalTxTypes = std::to_array<TxType>({
ttTRANSACTION_PROPOSAL_CREATE,
ttTRANSACTION_PROPOSAL_CANCEL,
});
} // namespace
bool
isValidProposal(STObject const& proposedTx)
isValidProposalTxnType(STObject const& proposedTx)
{
if (isProposalTx(proposedTx))
auto const type = proposedTx.getFieldU16(sfTransactionType);
if (std::ranges::find(kProposalTxTypes, type) != kProposalTxTypes.end())
return false;
if (isPseudoTx(proposedTx))
@@ -22,13 +55,24 @@ isValidProposal(STObject const& proposedTx)
(proposedTx.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)
return false;
if (proposedTx.getFieldU16(sfTransactionType) == ttBATCH &&
proposedTx.isFieldPresent(sfRawTransactions))
if (type == ttBATCH && proposedTx.isFieldPresent(sfRawTransactions))
{
STArray const& innerTxns = proposedTx.getFieldArray(sfRawTransactions);
for (STObject const& inner : innerTxns)
{
if (isProposalTx(inner) || isPseudoTx(inner))
// The only production caller reaches this walk through STTx
// construction, which rejects a typeless inner first, so this is
// re-checked rather than relied upon.
if (!inner.isFieldPresent(sfTransactionType))
return false;
auto const innerType = inner.getFieldU16(sfTransactionType);
// A nested Batch is rejected alongside nested proposals and
// pseudo-transactions: STTx construction forbids inner Batches, so
// a proposal must not be able to store one either.
if (std::ranges::find(kProposalTxTypes, innerType) != kProposalTxTypes.end() ||
innerType == ttBATCH || isPseudoTx(inner))
return false;
}
}
@@ -36,4 +80,112 @@ isValidProposal(STObject const& proposedTx)
return true;
}
bool
isTerminal(
ReadView const& view,
std::optional<std::uint32_t> expiration,
STObject const& proposedTx)
{
if (hasExpired(view, expiration))
return true;
// Mirror the ordinary transactor's tefMAX_LEDGER rule: view.seq() > bound.
// At view.seq() == bound the payload can still be submitted, so the
// proposal is still live (On-Chain Cosigner spec §4.5).
return proposedTx.isFieldPresent(sfLastLedgerSequence) &&
view.seq() > proposedTx.getFieldU32(sfLastLedgerSequence);
}
TER
deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j)
{
if (!sleProposal || sleProposal->getType() != ltTRANSACTION_PROPOSAL ||
!view.exists(Keylet{ltTRANSACTION_PROPOSAL, sleProposal->key()}))
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Invalid TransactionProposal deletion.";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
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;
}
bool
payloadMatches(STObject const& proposedTx, STObject const& tx)
{
XRPL_ASSERT(
proposedTx.isFieldPresent(sfTransactionType) && tx.isFieldPresent(sfTransactionType),
"xrpl::proposal::payloadMatches : transaction-shaped inputs");
// The signing-payload serialization omits every kNotSigning field: the
// signature containers a proposal lets evolve (TxnSignature, Signers,
// BatchSigners, CounterpartySignature, SponsorSignature) plus Signature
// and MasterSignature, which the account's own signature does not cover
// either (see the header). What remains is the field set fixed at
// creation — except SigningPubKey, which signing payloads include but
// which is also mutable here (stored empty, filled if the target signs
// with its own key). Neutralize it on both sides before comparing.
auto const payloadBytes = [](STObject const& obj) {
STObject copy{obj};
copy.setFieldVL(sfSigningPubKey, Slice{});
Serializer s;
copy.addWithoutSigningFields(s);
return s.getData();
};
return payloadBytes(proposedTx) == payloadBytes(tx);
}
bool
canConsumeTicket(ReadView const& view, STTx const& tx)
{
auto const seqProx = tx.getSeqProxy();
if (!seqProx.isTicket())
{
// The caller only reaches here for a Ticket-based transaction.
// LCOV_EXCL_START
UNREACHABLE("xrpl::proposal::canConsumeTicket : tx spends a Sequence");
return false;
// LCOV_EXCL_STOP
}
auto const sleProposal =
view.read(keylet::txProposal(tx.getAccountID(sfAccount), seqProx.value()));
if (!sleProposal)
return true;
return payloadMatches(sleProposal->getFieldObject(sfProposedTransaction), tx);
}
} // namespace xrpl::proposal
+1
View File
@@ -228,6 +228,7 @@ transResults()
MAKE_ERROR(terNO_DELEGATE_PERMISSION, "Delegated account lacks permission to perform this transaction."),
MAKE_ERROR(terLOCKED, "Fund is locked."),
MAKE_ERROR(terNO_PERMISSION, "No permission to perform requested operation."),
MAKE_ERROR(terTICKET_RESERVED, "Ticket is reserved."),
MAKE_ERROR(tesSUCCESS, "The transaction was applied. Only final in a validated ledger."),
};
+35 -2
View File
@@ -16,6 +16,7 @@
#include <xrpl/ledger/helpers/DelegateHelpers.h>
#include <xrpl/ledger/helpers/NFTokenHelpers.h>
#include <xrpl/ledger/helpers/OfferHelpers.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/AccountID.h>
@@ -327,10 +328,9 @@ Transactor::preflight2(PreflightContext const& ctx)
auto const sigValid = checkValidity(ctx.registry.get().getHashRouter(), ctx.tx, ctx.rules);
if (sigValid.first == Validity::SigBad)
{ // LCOV_EXCL_START
{
JLOG(ctx.j.debug()) << "preflight2: bad signature. " << sigValid.second;
return temINVALID;
// LCOV_EXCL_STOP
}
// Do not add any checks after this point that are relevant for
@@ -755,6 +755,19 @@ Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j
<< "a_seq=" << aSeq << " t_seq=" << tSeqProx;
return tefNO_TICKET;
}
// While a TransactionProposal keyed to this Ticket exists, the
// Ticket is reserved: only the proposal's own proposed transaction
// may consume it, so unrelated activity cannot invalidate the
// proposal while signatures are being collected (On-Chain Cosigner spec §4.2.1).
// Cancelling the proposal frees the Ticket, so this is a retry, not
// a final failure. No proposal can exist before the Cosign amendment,
// so the gate only makes that dependency explicit.
if (view.rules().enabled(featureCosign) && !proposal::canConsumeTicket(view, tx))
{
JLOG(j.trace()) << "applyTransaction: ticket " << tSeqProx << " is reserved";
return terTICKET_RESERVED;
}
}
return tesSUCCESS;
@@ -866,8 +879,28 @@ 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 (On-Chain Cosigner spec §4.5).
// The reservation check in checkSeqProxy means this is reached only by
// the proposal's own proposed transaction executing (or failing with a
// claimed-fee tec), or by AccountDelete sweeping the target account's
// Tickets. No proposal can exist before the Cosign amendment, so the gate
// only makes that dependency explicit.
if (view.rules().enabled(featureCosign))
{
if (auto const sleProposal = view.peek(keylet::txProposal(account, ticketSeq)))
{
if (TER const ter = proposal::deleteProposal(view, sleProposal, j); !isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
}
}
return tesSUCCESS;
}
@@ -0,0 +1,119 @@
#include <xrpl/tx/transactors/proposal/TransactionProposalCancel.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.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/Transactor.h>
namespace xrpl {
NotTEC
TransactionProposalCancel::preflight(PreflightContext const& ctx)
{
if (ctx.tx[sfProposalID] == beast::kZero)
{
JLOG(ctx.j.debug()) << "TransactionProposalCancel: zero ProposalID.";
return temMALFORMED;
}
return tesSUCCESS;
}
TER
TransactionProposalCancel::preclaim(PreclaimContext const& ctx)
{
uint256 const proposalID = ctx.tx[sfProposalID];
// Never read the zero keylet. Preflight already rejected a zero
// ProposalID, and no real proposal can have one (every ProposalID is a
// hash), so reaching here with zero is an internal error. (and a mathematical rarity. )
if (proposalID == beast::kZero)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const sleProposal = ctx.view.read(keylet::txProposal(proposalID));
if (!sleProposal)
{
JLOG(ctx.j.debug()) << "TransactionProposalCancel: proposal does not exist.";
return tecNO_ENTRY;
}
STObject const proposedTx = sleProposal->getFieldObject(sfProposedTransaction);
// A terminal proposal is closed to new signatures, so anyone may delete
// it and release the Owner's reserve (On-Chain Cosigner spec §7.2). Being
// terminal does not stop an already signed payload from executing; see
// proposal::isTerminal.
bool const terminal = proposal::isTerminal(ctx.view, (*sleProposal)[~sfExpiration], proposedTx);
// A live proposal may only be cancelled by its Owner (the proposer) or
// by its target. The target is the account the proposed transaction
// would execute against (its Account field) or the Delegate named in it.
// Targets may always refuse a proposal, because anyone can create one
// against any account and doing so ties up one of that account's
// tickets (On-Chain Cosigner spec §7.2).
//
// The Delegate is read from the stored payload as-is; whether that
// delegation exists on-ledger is not checked. The proposal asks that
// account to sign, so that account may refuse — and refusing only
// deletes the proposer's own entry and returns the proposer's own
// reserve.
AccountID const account = ctx.tx[sfAccount];
bool const ownerOrTarget = account == sleProposal->getAccountID(sfOwner) ||
account == proposedTx.getAccountID(sfAccount) || account == proposedTx[~sfDelegate];
if (!terminal && !ownerOrTarget)
{
JLOG(ctx.j.debug()) << "TransactionProposalCancel: proposal is live and "
"canceller is neither its Owner nor its target.";
return tecNO_PERMISSION;
}
return tesSUCCESS;
}
TER
TransactionProposalCancel::doApply()
{
uint256 const proposalID = ctx_.tx[sfProposalID];
// Never read the zero keylet; see the matching guard in preclaim.
if (proposalID == beast::kZero)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const sleProposal = view().peek(keylet::txProposal(proposalID));
if (!sleProposal)
return tefINTERNAL; // LCOV_EXCL_LINE
return proposal::deleteProposal(view(), sleProposal, ctx_.registry.get().getJournal("View"));
}
void
TransactionProposalCancel::visitInvariantEntry(bool, SLE::ConstRef, SLE::ConstRef)
{
// No transaction-specific invariants yet (future work).
}
bool
TransactionProposalCancel::finalizeInvariants(
STTx const&,
TER,
XRPAmount,
ReadView const&,
beast::Journal const&)
{
// No transaction-specific invariants yet (future work).
return true;
}
} // namespace xrpl
@@ -84,7 +84,7 @@ TransactionProposalCreate::preflight(PreflightContext const& ctx)
// 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))
if (!proposal::isValidProposalTxnType(proposedTx))
{
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is not "
"independently submittable.";
File diff suppressed because it is too large. Load diff
@@ -19,11 +19,13 @@
#include <test/jtx/token.h>
#include <test/jtx/trust.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -36,6 +38,7 @@
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/jss.h>
#include <chrono> // IWYU pragma: keep
@@ -44,6 +47,7 @@
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace xrpl::test {
@@ -163,7 +167,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite
// Proposals do not nest. This payload also isn't a valid instance of
// TransactionProposalCreate's own template (it lacks Expiration and
// ProposedTransaction), so it fails STTx construction before ever
// reaching our own isProposalTx check.
// reaching our own nesting check.
{
json::Value tx = payload();
tx[jss::TransactionType] = "TransactionProposalCreate";
@@ -189,6 +193,49 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite
reject(tx, temINVALID);
}
// A Batch inner lacking TransactionType cannot be constructed as an
// STTx, so Create rejects the payload as temMALFORMED before the
// nesting walk runs at all. That inner would otherwise make the walk
// report temINVALID, so this pins the ordering.
{
json::Value missingType;
missingType[jss::Account] = target.human();
missingType[jss::Flags] = tfInnerBatchTxn;
missingType[jss::Sequence] = env.seq(target);
missingType[jss::Fee] = "10";
missingType[jss::SigningPubKey] = "";
reject(
proposal::unsignedBatch(
env,
target,
targetTicketSeq,
tfAllOrNothing,
{
proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1),
missingType,
}),
temMALFORMED);
}
// The walk rejects such an inner in its own right. Nothing submitted
// through Create can reach that guard — the case above shows STTx
// construction throwing first — so it is checked directly here, which
// also pins it for a caller that does not build an STTx beforehand.
{
STObject inner(sfRawTransaction);
inner.setAccountID(sfAccount, target.id());
STArray rawTxns(sfRawTransactions);
rawTxns.push_back(std::move(inner));
STObject batch(sfGeneric);
batch.setFieldU16(sfTransactionType, ttBATCH);
batch.setFieldArray(sfRawTransactions, rawTxns);
BEAST_EXPECT(!xrpl::proposal::isValidProposalTxnType(batch));
}
// The proposed transaction must be ticket-based: a missing
// TicketSequence, or a live Sequence alongside it, is rejected.
{
File diff suppressed because it is too large. Load diff
+10
View File
@@ -47,6 +47,16 @@ create(Account const& proposer, json::Value const& proposedTx, std::uint32_t exp
return jv;
}
json::Value
cancel(Account const& account, uint256 const& proposalID)
{
json::Value jv;
jv[jss::TransactionType] = jss::TransactionProposalCancel;
jv[jss::Account] = account.human();
jv[sfProposalID.jsonName] = to_string(proposalID);
return jv;
}
json::Value
unsignedPayload(Env const& env, json::Value tx, std::uint32_t ticketSeq)
{
+11
View File
@@ -4,6 +4,7 @@
#include <test/jtx/Env.h>
#include <test/jtx/JTx.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/ProposalHelpers.h>
@@ -40,6 +41,16 @@ using xrpl::proposal::kProposalOwnerCount;
json::Value
create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration);
/**
* @brief Build a TransactionProposalCancel naming the proposal to delete.
*
* @param account The account requesting the cancellation.
* @param proposalID The ID of the TransactionProposal to cancel.
* @return The TransactionProposalCancel JSON object.
*/
json::Value
cancel(Account const& account, uint256 const& proposalID);
/**
* @brief Conditions that check what a proposal transaction did to the ledger.
*
@@ -0,0 +1,146 @@
// Auto-generated unit tests for transaction TransactionProposalCancel
#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/TransactionProposalCancel.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(TransactionsTransactionProposalCancelTests, BuilderSettersRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCancel"));
// 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 proposalIDValue = canonical_UINT256();
TransactionProposalCancelBuilder builder{
accountValue,
proposalIDValue,
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 = proposalIDValue;
auto const actual = tx.getProposalID();
expectEqualField(expected, actual, "sfProposalID");
}
// Verify optional fields
}
// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper,
// and verify all fields match.
TEST(TransactionsTransactionProposalCancelTests, BuilderFromStTxRoundTrip)
{
// Generate a deterministic keypair for signing
auto const [publicKey, secretKey] =
generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCancelFromTx"));
// 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 proposalIDValue = canonical_UINT256();
// Build an initial transaction
TransactionProposalCancelBuilder initialBuilder{
accountValue,
proposalIDValue,
sequenceValue,
feeValue
};
auto initialTx = initialBuilder.build(publicKey, secretKey);
// Create builder from existing STTx
TransactionProposalCancelBuilder 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 = proposalIDValue;
auto const actual = rebuiltTx.getProposalID();
expectEqualField(expected, actual, "sfProposalID");
}
// Verify optional fields
}
// 3) Verify wrapper throws when constructed from wrong transaction type.
TEST(TransactionsTransactionProposalCancelTests, 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(TransactionProposalCancel{wrongTx.getSTTx()}, std::runtime_error);
}
// 4) Verify builder throws when constructed from wrong transaction type.
TEST(TransactionsTransactionProposalCancelTests, 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(TransactionProposalCancelBuilder{wrongTx.getSTTx()}, std::runtime_error);
}
}
+13 -12
View File
@@ -15,7 +15,7 @@ namespace xrpl::test {
namespace {
// A bare STObject carrying only sfTransactionType. isValidProposal accepts an
// A bare STObject carrying only sfTransactionType. isValidProposalTxnType accepts an
// STObject (not an STTx) precisely so that this file's defense-in-depth
// checks can be tested without the surrounding STTx format validation.
inline STObject
@@ -45,7 +45,7 @@ batchWrapping(STObject inner)
// The happy path — an ordinary Payment is independently submittable.
TEST(ProposalHelpers, plain_payment_is_valid)
{
EXPECT_TRUE(proposal::isValidProposal(txOfType(ttPAYMENT)));
EXPECT_TRUE(proposal::isValidProposalTxnType(txOfType(ttPAYMENT)));
}
// A nested TransactionProposalCreate. In practice STTx construction rejects
@@ -54,16 +54,16 @@ TEST(ProposalHelpers, plain_payment_is_valid)
// drift apart.
TEST(ProposalHelpers, nested_proposal_is_rejected)
{
EXPECT_FALSE(proposal::isValidProposal(txOfType(ttTRANSACTION_PROPOSAL_CREATE)));
EXPECT_FALSE(proposal::isValidProposalTxnType(txOfType(ttTRANSACTION_PROPOSAL_CREATE)));
}
// Any pseudo-transaction — see STTx::isPseudoTx. Also normally caught earlier
// by STTx construction / preflight0.
TEST(ProposalHelpers, pseudo_tx_is_rejected)
{
EXPECT_FALSE(proposal::isValidProposal(txOfType(ttAMENDMENT)));
EXPECT_FALSE(proposal::isValidProposal(txOfType(ttFEE)));
EXPECT_FALSE(proposal::isValidProposal(txOfType(ttUNL_MODIFY)));
EXPECT_FALSE(proposal::isValidProposalTxnType(txOfType(ttAMENDMENT)));
EXPECT_FALSE(proposal::isValidProposalTxnType(txOfType(ttFEE)));
EXPECT_FALSE(proposal::isValidProposalTxnType(txOfType(ttUNL_MODIFY)));
}
// tfInnerBatchTxn marks a transaction as an inner leg of an enclosing Batch,
@@ -74,7 +74,7 @@ TEST(ProposalHelpers, inner_batch_flag_is_rejected)
{
STObject tx = txOfType(ttPAYMENT);
tx.setFieldU32(sfFlags, tfInnerBatchTxn);
EXPECT_FALSE(proposal::isValidProposal(tx));
EXPECT_FALSE(proposal::isValidProposalTxnType(tx));
}
// A Flags value that is present but does not include tfInnerBatchTxn must
@@ -83,26 +83,27 @@ TEST(ProposalHelpers, other_flags_are_accepted)
{
STObject tx = txOfType(ttPAYMENT);
tx.setFieldU32(sfFlags, tfFullyCanonicalSig);
EXPECT_TRUE(proposal::isValidProposal(tx));
EXPECT_TRUE(proposal::isValidProposalTxnType(tx));
}
// A Batch wrapping a plain inner is fine — the loop is only there to catch
// specifically forbidden inner types.
TEST(ProposalHelpers, batch_with_plain_inner_is_valid)
{
EXPECT_TRUE(proposal::isValidProposal(batchWrapping(txOfType(ttPAYMENT))));
EXPECT_TRUE(proposal::isValidProposalTxnType(batchWrapping(txOfType(ttPAYMENT))));
}
// A Batch whose inner is itself a proposal must be rejected.
TEST(ProposalHelpers, batch_with_nested_proposal_inner_is_rejected)
{
EXPECT_FALSE(proposal::isValidProposal(batchWrapping(txOfType(ttTRANSACTION_PROPOSAL_CREATE))));
EXPECT_FALSE(
proposal::isValidProposalTxnType(batchWrapping(txOfType(ttTRANSACTION_PROPOSAL_CREATE))));
}
// A Batch whose inner is a pseudo-transaction must be rejected.
TEST(ProposalHelpers, batch_with_pseudo_inner_is_rejected)
{
EXPECT_FALSE(proposal::isValidProposal(batchWrapping(txOfType(ttAMENDMENT))));
EXPECT_FALSE(proposal::isValidProposalTxnType(batchWrapping(txOfType(ttAMENDMENT))));
}
// A Batch with no sfRawTransactions field skips the inner-loop entirely.
@@ -110,7 +111,7 @@ TEST(ProposalHelpers, batch_with_pseudo_inner_is_rejected)
// helper (the field is optional at the STObject level) and should hold.
TEST(ProposalHelpers, batch_without_raw_transactions_is_valid)
{
EXPECT_TRUE(proposal::isValidProposal(txOfType(ttBATCH)));
EXPECT_TRUE(proposal::isValidProposalTxnType(txOfType(ttBATCH)));
}
} // namespace xrpl::test