diff --git a/include/xrpl/ledger/ApplyView.h b/include/xrpl/ledger/ApplyView.h index 724d89b7c6a..1b2cd3d7e1f 100644 --- a/include/xrpl/ledger/ApplyView.h +++ b/include/xrpl/ledger/ApplyView.h @@ -43,7 +43,13 @@ enum ApplyFlags : std::uint32_t { // Transaction shouldn't be applied // Signatures shouldn't be checked - TapDryRun = 0x1000 + TapDryRun = 0x1000, + + // Transaction is being preflighted as the payload of a + // TransactionProposalCreate. Its signatures are collected on-ledger + // afterward, so signature-presence checks (e.g. Batch signer matching) + // are skipped at proposal-creation time (spec §5.3.1.2). + TapProposal = 0x2000 }; constexpr ApplyFlags diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..025ec77396e 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1194,6 +1194,20 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCount, SoeOptional}, })) +/** This transaction posts an unsigned transaction on-ledger as a + TransactionProposal, pending multi-signature collection. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttTRANSACTION_PROPOSAL_CREATE, 92, TransactionProposalCreate, + Delegation::NotDelegable, + featureCosign, + NoPriv, + ({ + {sfProposedTransaction, SoeRequired}, + {sfExpiration, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h new file mode 100644 index 00000000000..47eed43ee72 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/TransactionProposalCreate.h @@ -0,0 +1,155 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class TransactionProposalCreateBuilder; + +/** + * @brief Transaction: TransactionProposalCreate + * + * Type: ttTRANSACTION_PROPOSAL_CREATE (92) + * Delegable: Delegation::NotDelegable + * Amendment: featureCosign + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use TransactionProposalCreateBuilder to construct new transactions. + */ +class TransactionProposalCreate : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttTRANSACTION_PROPOSAL_CREATE; + + /** + * @brief Construct a TransactionProposalCreate transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit TransactionProposalCreate(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for TransactionProposalCreate"); + } + } + + // Transaction-specific field getters + /** + * @brief Get sfProposedTransaction (SoeRequired) + * @note This is an untyped field. + * @return The field value. + */ + [[nodiscard]] + STObject + getProposedTransaction() const + { + return this->tx_->getFieldObject(sfProposedTransaction); + } + + /** + * @brief Get sfExpiration (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getExpiration() const + { + return this->tx_->at(sfExpiration); + } +}; + +/** + * @brief Builder for TransactionProposalCreate transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class TransactionProposalCreateBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new TransactionProposalCreateBuilder with required fields. + * @param account The account initiating the transaction. + * @param proposedTransaction The sfProposedTransaction field value. + * @param expiration The sfExpiration field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + TransactionProposalCreateBuilder(SF_ACCOUNT::type::value_type account, + STObject const& proposedTransaction, std::decay_t const& expiration, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttTRANSACTION_PROPOSAL_CREATE, account, sequence, fee) + { + setProposedTransaction(proposedTransaction); + setExpiration(expiration); + } + + /** + * @brief Construct a TransactionProposalCreateBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + TransactionProposalCreateBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttTRANSACTION_PROPOSAL_CREATE) + { + throw std::runtime_error("Invalid transaction type for TransactionProposalCreateBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfProposedTransaction (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalCreateBuilder& + setProposedTransaction(STObject const& value) + { + object_.setFieldObject(sfProposedTransaction, value); + return *this; + } + + /** + * @brief Set sfExpiration (SoeRequired) + * @return Reference to this builder for method chaining. + */ + TransactionProposalCreateBuilder& + setExpiration(std::decay_t const& value) + { + object_[sfExpiration] = value; + return *this; + } + + /** + * @brief Build and return the TransactionProposalCreate wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + TransactionProposalCreate + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return TransactionProposalCreate{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h new file mode 100644 index 00000000000..c899e154821 --- /dev/null +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +#include + +namespace xrpl { + +/** + * Whether the proposed transaction is itself a proposal transaction, which + * would nest one proposal inside another. + * + * TODO: cover ttTRANSACTION_PROPOSAL_SIGN and ttTRANSACTION_PROPOSAL_CANCEL + * once those transactions exist. + */ +inline bool +isProposalTx(STObject const& proposedTx) +{ + return proposedTx.getFieldU16(sfTransactionType) == ttTRANSACTION_PROPOSAL_CREATE; +} + +/** + * Whether the proposed transaction carries any signature field. + * + * 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(); +} + +/** + * 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; +} + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h new file mode 100644 index 00000000000..a8c36b5611b --- /dev/null +++ b/include/xrpl/tx/transactors/proposal/TransactionProposalCreate.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class TransactionProposalCreate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit TransactionProposalCreate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp new file mode 100644 index 00000000000..9da50c201ab --- /dev/null +++ b/src/libxrpl/tx/transactors/proposal/TransactionProposalCreate.cpp @@ -0,0 +1,252 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +NotTEC +TransactionProposalCreate::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfExpiration] == 0) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: zero expiration."; + return temBAD_EXPIRATION; + } + + STObject const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); + + if (!proposedTx.isFieldPresent(sfTransactionType) || !proposedTx.isFieldPresent(sfAccount)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "lacks TransactionType or Account."; + return temMALFORMED; + } + + // The proposed transaction must be independently submittable through the + // ordinary multi-sign path: no nested proposals, no pseudo-transactions, + // no batch inner transactions. + if (isProposalTx(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: nested proposal."; + return temINVALID; + } + + if (isPseudoTx(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is a " + "pseudo-transaction."; + return temINVALID; + } + + if (proposedTx.isFieldPresent(sfFlags) && + ((proposedTx.getFieldU32(sfFlags) & tfInnerBatchTxn) != 0u)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "carries tfInnerBatchTxn."; + return temINVALID; + } + + // The proposed transaction is stored in its unsigned canonical form; the + // ledger populates its signature fields as contributions arrive. + if (hasSignatureField(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "carries signature fields."; + return temBAD_SIGNER; + } + + if (!hasEmptySigningPubKey(proposedTx)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "SigningPubKey must be present and empty."; + return temBAD_SIGNER; + } + + // The proposed transaction's fee is charged to the target account when + // the completed transaction is submitted, so it must be fixed now. + if (!proposedTx.isFieldPresent(sfFee) || !proposedTx.isFieldPresent(sfSequence)) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn " + "lacks Fee or Sequence."; + return temMALFORMED; + } + + // The proposed transaction must be ticket-based: it must carry a + // TicketSequence and must not use a live Sequence. Sequence is a required + // common field, so "no Sequence" is expressed as a Sequence of 0 rather + // than an absent field. A ticket decouples the proposal from the target + // account's live sequence, so unrelated target-account activity cannot + // invalidate it while signatures are collected (spec §4.2.1). + if (!proposedTx.isFieldPresent(sfTicketSequence) || proposedTx.getFieldU32(sfSequence) != 0) + return temSEQ_AND_TICKET; + + // The proposed transaction must pass its own static checks under the + // current rules, so no statically-dead proposal can be stored. TapDryRun + // accepts the unsigned canonical form without a signature check; + // TapProposal additionally skips signature-presence checks (e.g. Batch + // signer matching), which are deferred to submission time (spec §5.3.1.2). + try + { + STTx const stx{STObject{proposedTx}}; + auto const inner = + xrpl::preflight(ctx.registry, ctx.rules, stx, TapDryRun | 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 (spec §5.3.1). + return inner.ter; + } + } + catch (std::exception const& e) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposed txn is " + "malformed: " + << e.what(); + return temMALFORMED; + } + + return tesSUCCESS; +} + +TER +TransactionProposalCreate::preclaim(PreclaimContext const& ctx) +{ + if (hasExpired(ctx.view, ctx.tx[~sfExpiration])) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: already expired."; + return tecEXPIRED; + } + + auto const proposedTx = ctx.tx.getFieldObject(sfProposedTransaction); + + // Once the proposed transaction's own ledger bound has passed it can never + // be applied, so the proposal is dead on arrival. The bound is the one the + // ordinary path uses for tefMAX_LEDGER: the last ledger in which the + // proposed transaction may still be submitted (spec §4.5). + if (proposedTx.isFieldPresent(sfLastLedgerSequence) && + proposedTx.getFieldU32(sfLastLedgerSequence) < ctx.view.seq()) + { + 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; + + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); + + if (ctx.view.exists(keylet::txProposal(target, ticketSequence))) + { + JLOG(ctx.j.debug()) << "TransactionProposalCreate: duplicate proposal."; + return tecDUPLICATE; + } + + return tesSUCCESS; +} + +TER +TransactionProposalCreate::doApply() +{ + auto const sle = view().peek(keylet::account(accountID_)); + if (!sle) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const proposedTx = ctx_.tx.getFieldObject(sfProposedTransaction); + std::uint32_t const ownerCount = proposalOwnerCount(proposedTx); + + // The proposal holds a full transaction plus its collected signatures, so + // it reserves more than a typical ledger entry (5 increments; 10 for a + // proposed Batch). + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), + sle, + preFeeBalance_, + {.ownerCountDelta = static_cast(ownerCount)}, + ctx_.journal); + !isTesSuccess(ret)) + return ret; + + AccountID const target = proposedTx.getAccountID(sfAccount); + std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence); + + Keylet const proposalKeylet = keylet::txProposal(target, ticketSequence); + auto sleProposal = std::make_shared(proposalKeylet); + sleProposal->setAccountID(sfOwner, accountID_); + sleProposal->setFieldObject(sfProposedTransaction, proposedTx); + sleProposal->setFieldU32(sfExpiration, ctx_.tx[sfExpiration]); + + view().insert(sleProposal); + + auto viewJ = ctx_.registry.get().getJournal("View"); + { + auto const page = view().dirInsert( + keylet::ownerDir(accountID_), proposalKeylet, describeOwnerDir(accountID_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + sleProposal->setFieldU64(sfOwnerNode, *page); + } + + increaseOwnerCount(ctx_.getApplyViewContext(), sle, ownerCount, viewJ); + addSponsorToLedgerEntry(ctx_.getApplyViewContext(), sleProposal); + return tesSUCCESS; +} + +void +TransactionProposalCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). Object-level + // invariants for the TransactionProposal ledger entry (unsigned canonical + // form, non-zero Expiration, correct ProposalID key, sorted/unique signer + // arrays) belong in a protocol-level ValidTransactionProposal check. +} + +bool +TransactionProposalCreate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/system/Batch.cpp b/src/libxrpl/tx/transactors/system/Batch.cpp index ccb113e07ba..290345ecb56 100644 --- a/src/libxrpl/tx/transactors/system/Batch.cpp +++ b/src/libxrpl/tx/transactors/system/Batch.cpp @@ -406,6 +406,13 @@ Batch::preflightSigValidated(PreflightContext const& ctx) { XRPL_ASSERT( ctx.tx.getTxnType() == ttBATCH, "xrpl::Batch::preflightSigValidated : batch transaction"); + + // A proposed Batch is stored unsigned; its BatchSigners are collected + // on-ledger afterward, so the signer-presence match belongs to submission + // time, not proposal creation (spec §5.3.1.2). + if ((ctx.flags & TapProposal) != 0) + return tesSUCCESS; + auto const parentBatchId = ctx.tx.getTransactionID(); auto const outerAccount = ctx.tx.getAccountID(sfAccount); // Accounts that must sign the batch: each inner authorizer and counterparty diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp new file mode 100644 index 00000000000..05c24e3c690 --- /dev/null +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -0,0 +1,588 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +struct TransactionProposalCreate_test : public beast::unit_test::Suite +{ + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env const& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + + void + testCreate(FeatureBitset features) + { + testcase("create proposal object"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; // the proposer + Account const target{"target"}; // the account the proposal is for + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // The proposed transaction is stored unsigned: no signature fields and + // an empty SigningPubKey. It is ticket-based so unrelated target account + // activity cannot invalidate it while signatures are collected. + json::Value const proposedTx = unsignedPayload(env, target, bob, targetTicketSeq); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), targetTicketSeq)); + BEAST_EXPECT(sle); + if (!sle) + return; + + BEAST_EXPECT(sle->getAccountID(sfOwner) == alice.id()); + BEAST_EXPECT(sle->getFieldU32(sfExpiration) == expiration); + + auto const stored = sle->getFieldObject(sfProposedTransaction); + BEAST_EXPECT(stored.getAccountID(sfAccount) == target.id()); + BEAST_EXPECT(stored.getFieldU32(sfSequence) == 0); + BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq); + BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty()); + + // The proposal reserves several owner increments against the proposer. + // The target only owns the Ticket used by the proposed transaction. + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // The proposed transaction must be storable in unsigned canonical form and + // must be a transaction that could be submitted on its own. Each case below + // takes an otherwise valid payload and breaks exactly one of those rules. + void + testRejectedPayload(FeatureBitset features) + { + testcase("reject payload that must not be stored"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + // A payload that is accepted as-is; every case starts from this. + auto payload = [&]() { return unsignedPayload(env, target, bob, targetTicketSeq); }; + + auto reject = [&](json::Value const& proposedTx, TER expected) { + env(proposalCreate(alice, proposedTx, expiration), Ter(expected)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + }; + + // Signatures may only ever arrive through TransactionProposalSign. + { + json::Value tx = payload(); + tx[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + auto& signer = tx[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; + signer[jss::Account] = bob.human(); + signer[jss::SigningPubKey] = strHex(bob.pk().slice()); + signer[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + + // SigningPubKey must be present and empty: absent is not the same as + // empty, and a set key means the payload was signed for single-signing. + { + json::Value tx = payload(); + tx.removeMember(jss::SigningPubKey); + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[jss::SigningPubKey] = strHex(target.pk().slice()); + reject(tx, temBAD_SIGNER); + } + + // A pseudo-transaction is never submittable by an account. + { + json::Value tx = payload(); + tx[jss::TransactionType] = jss::EnableAmendment; + reject(tx, temINVALID); + } + + // An inner batch transaction bypasses the ordinary signature checks. + { + json::Value tx = payload(); + tx[jss::Flags] = tfInnerBatchTxn; + reject(tx, temINVALID); + } + + // Proposals do not nest. + { + json::Value tx = payload(); + tx[jss::TransactionType] = "TransactionProposalCreate"; + reject(tx, temINVALID); + } + + // The remaining signature containers are just as forbidden as a bare + // TxnSignature or Signers array. + { + json::Value tx = payload(); + auto& bs = tx[sfBatchSigners.getJsonName()][0u][sfBatchSigner.getJsonName()]; + bs[jss::Account] = bob.human(); + bs[jss::SigningPubKey] = strHex(bob.pk().slice()); + bs[sfTxnSignature.getJsonName()] = "DEADBEEF"; + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[sfCounterpartySignature.getJsonName()][jss::SigningPubKey] = + strHex(bob.pk().slice()); + reject(tx, temBAD_SIGNER); + } + { + json::Value tx = payload(); + tx[sfSponsorSignature.getJsonName()][jss::SigningPubKey] = strHex(bob.pk().slice()); + reject(tx, temBAD_SIGNER); + } + + // The proposed transaction must be ticket-based: a missing + // TicketSequence, or a live Sequence alongside it, is rejected. + { + json::Value tx = payload(); + tx.removeMember(sfTicketSequence.getJsonName()); + reject(tx, temSEQ_AND_TICKET); + } + { + json::Value tx = payload(); + tx[jss::Sequence] = 1; + reject(tx, temSEQ_AND_TICKET); + } + + // A payload that fails its own transaction type's preflight surfaces + // that type's own code, not a generic error (spec §5.3.1.2). + { + json::Value tx = payload(); + tx[jss::Amount] = "0"; + reject(tx, temBAD_AMOUNT); + } + + // Expiration must be present and non-zero. + { + env(proposalCreate(alice, payload(), 0), Ter(temBAD_EXPIRATION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + } + + // Nothing about the transaction is available before the amendment is + // active, not even to an otherwise valid proposal. + void + testDisabled(FeatureBitset features) + { + testcase("amendment disabled"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features - featureCosign}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, targetTicketSeq), expiration), + Ter(temDISABLED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // A proposal that could never be completed must not be stored, and a + // target-and-ticket pair may hold at most one proposal. + void + testPreclaim(FeatureBitset features) + { + testcase("reject proposal that cannot be completed"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const carol{"carol"}; // never funded + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const firstTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 3)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + // The proposal's own expiration has already passed. + { + std::uint32_t const past = env.now().time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, firstTicketSeq), past), + Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // The proposed transaction's own ledger bound has passed: the ordinary + // path would reject it with tefMAX_LEDGER, so it can never complete. + { + json::Value tx = unsignedPayload(env, target, bob, firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1; + env(proposalCreate(alice, tx, expiration), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // The last ledger in which the proposed transaction may still be + // submitted is the current one, so the proposal is still alive. + { + json::Value tx = unsignedPayload(env, target, bob, firstTicketSeq); + tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); + env(proposalCreate(alice, tx, expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), firstTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // The target and ticket already carry a proposal. + { + env(proposalCreate( + alice, unsignedPayload(env, target, bob, firstTicketSeq), expiration), + Ter(tecDUPLICATE)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // A different ticket of the same target is a different proposal. + { + env(proposalCreate( + alice, unsignedPayload(env, target, bob, firstTicketSeq + 1), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), firstTicketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + } + + // The target account does not exist, so it can never sign. + { + env(proposalCreate(alice, unsignedPayload(env, carol, bob, 1), expiration), + Ter(tecNO_TARGET)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(carol.id(), 1))); + BEAST_EXPECT(ownerCount(env, alice) == 2 * kProposalOwnerCount); + } + } + + // The proposer holds the proposal's reserve until it is resolved. + void + testReserve(FeatureBitset features) + { + testcase("proposer reserve"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // Fund alice just short of the reserve the proposal requires. + env.fund(env.current()->fees().accountReserve(kProposalOwnerCount, 1) - drops(1), alice); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + json::Value const proposedTx = unsignedPayload(env, target, bob, targetTicketSeq); + + env(proposalCreate(alice, proposedTx, expiration), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + + env(pay(bob, alice, XRP(10))); + env.close(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kProposalOwnerCount); + } + + // A proposed Batch holds several inner transactions and the signatures of + // every account they touch, so it reserves more than an ordinary proposal. + void + testBatchReserve(FeatureBitset features) + { + testcase("proposed batch reserve"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](std::uint32_t seq) { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 0, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = inner(env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(env.seq(target) + 1); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + } + + // A multi-account Batch is the primary motivating case (spec §10): its inner + // transactions touch accounts other than the outer one, so submitting it + // directly would require a BatchSigners entry per participant. A proposal is + // stored unsigned, so those signatures are collected on-ledger afterward and + // the signer-presence match is skipped at creation time (spec §5.3.1.2). + void + testMultiAccountBatch(FeatureBitset features) + { + testcase("proposed multi-account batch"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; // outer account of the batch + Account const bob{"bob"}; // a distinct inner participant + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](Account const& from, Account const& to, std::uint32_t seq) { + json::Value tx = pay(from, to, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + // One inner from the outer account, one from bob: bob is a required + // signer, so a direct submission would need his BatchSigners entry. + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = targetTicketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 1, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = + inner(target, bob, env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = + inner(bob, target, env.seq(bob)); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), targetTicketSeq)); + BEAST_EXPECT(sle); + if (!sle) + return; + + // The proposal is stored without any BatchSigners: the participants' + // signatures are collected later through TransactionProposalSign. + auto const stored = sle->getFieldObject(sfProposedTransaction); + BEAST_EXPECT(!stored.isFieldPresent(sfBatchSigners)); + BEAST_EXPECT(ownerCount(env, alice) == kBatchProposalOwnerCount); + } + + // The target account must be able to authorize a transaction through a + // SignerList, so a pseudo-account (here an AMM's) cannot be a target even + // though it exists on-ledger (spec §5.3.2.5). + void + testPseudoTarget(FeatureBitset features) + { + testcase("reject proposal targeting a pseudo-account"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const proposer{"proposer"}; + Account const alice{"alice"}; // the AMM creator + Account const gw{"gw"}; + Account const bob{"bob"}; + // NOLINTNEXTLINE(readability-identifier-naming) + auto const USD = gw["USD"]; + env.fund(XRP(10000), proposer, alice, gw, bob); + env.close(); + env.trust(USD(1'000'000), alice); + env.close(); + env(pay(gw, alice, USD(10'000))); + env.close(); + + AMM const amm(env, alice, XRP(1'000), USD(1'000), Ter(tesSUCCESS)); + env.close(); + + // A well-formed Payment whose target is the AMM's pseudo-account. + json::Value proposedTx = pay(alice, bob, XRP(1)); + proposedTx[jss::Account] = toBase58(amm.ammAccount()); + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = 1; + proposedTx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + proposedTx[jss::SigningPubKey] = ""; + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + + env(proposalCreate(proposer, proposedTx, expiration), Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(amm.ammAccount(), 1))); + BEAST_EXPECT(ownerCount(env, proposer) == 0); + } + + void + run() override + { + using namespace jtx; + testDisabled(testableAmendments()); + testCreate(testableAmendments()); + testRejectedPayload(testableAmendments()); + testPreclaim(testableAmendments()); + testReserve(testableAmendments()); + testBatchReserve(testableAmendments()); + testMultiAccountBatch(testableAmendments()); + testPseudoTarget(testableAmendments()); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalCreate, app, xrpl); + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp new file mode 100644 index 00000000000..58d6b2d0e7a --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/TransactionProposalCreateTests.cpp @@ -0,0 +1,162 @@ +// Auto-generated unit tests for transaction TransactionProposalCreate + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsTransactionProposalCreateTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCreate")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + + TransactionProposalCreateBuilder builder{ + accountValue, + proposedTransactionValue, + expirationValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = proposedTransactionValue; + auto const actual = tx.getProposedTransaction(); + expectEqualField(expected, actual, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + auto const actual = tx.getExpiration(); + expectEqualField(expected, actual, "sfExpiration"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsTransactionProposalCreateTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testTransactionProposalCreateFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const proposedTransactionValue = canonical_OBJECT(); + auto const expirationValue = canonical_UINT32(); + + // Build an initial transaction + TransactionProposalCreateBuilder initialBuilder{ + accountValue, + proposedTransactionValue, + expirationValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + TransactionProposalCreateBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = proposedTransactionValue; + auto const actual = rebuiltTx.getProposedTransaction(); + expectEqualField(expected, actual, "sfProposedTransaction"); + } + + { + auto const& expected = expirationValue; + auto const actual = rebuiltTx.getExpiration(); + expectEqualField(expected, actual, "sfExpiration"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsTransactionProposalCreateTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(TransactionProposalCreate{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsTransactionProposalCreateTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(TransactionProposalCreateBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +}