mirror of
https://github.com/XRPLF/rippled.git
synced 2026-10-02 09:48:04 +00:00
357 lines
15 KiB
C++
357 lines
15 KiB
C++
#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/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/SeqProxy.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;
|
|
|
|
// deserialize itself returns unexpected(temMALFORMED) when
|
|
// sfSignerEntries is missing or an element is not an sfSignerEntry.
|
|
// Those are the right codes for a transaction object. Here the object
|
|
// is an on-ledger ltSIGNER_LIST (sfSignerEntries is SoeRequired;
|
|
// each element is an sfSignerEntry). A corrupt SLE can still throw
|
|
// from the STObject accessors deserialize calls: getFieldArray
|
|
// ("Wrong field type") or getAccountID/getFieldU16 ("Field not
|
|
// found") when an sfSignerEntry is missing required fields. Either
|
|
// the expected<> error or a throw is unexpected ledger state, not a
|
|
// malformed TransactionProposalCreate, so tefBAD_LEDGER (rather
|
|
// than tefINTERNAL, which is reserved for truly unreachable code
|
|
// paths) is the right code.
|
|
try
|
|
{
|
|
auto const accountSigners =
|
|
SignerEntries::deserialize(*sleSigners, ctx.j, "ledger");
|
|
if (!accountSigners)
|
|
{
|
|
JLOG(ctx.j.fatal()) << "TransactionProposalCreate: unparseable SignerList: "
|
|
<< transToken(accountSigners.error());
|
|
return std::unexpected(tefBAD_LEDGER);
|
|
}
|
|
|
|
return std::ranges::any_of(
|
|
*accountSigners, [&](auto const& entry) { return entry.account == proposer; });
|
|
}
|
|
catch (std::exception const& e)
|
|
{
|
|
JLOG(ctx.j.fatal())
|
|
<< "TransactionProposalCreate: unparseable SignerList: " << e.what();
|
|
return std::unexpected(tefBAD_LEDGER);
|
|
}
|
|
};
|
|
|
|
auto isSigner = isAuthorizedFor(target);
|
|
if (!isSigner)
|
|
return isSigner.error();
|
|
|
|
// A delegate that the target has granted permission over the
|
|
// proposed transaction — or one of that delegate's own signers — is
|
|
// equally authorized: it will need to help complete the proposed
|
|
// transaction's own authorization anyway once the proposal is
|
|
// submitted. xrpl::invokeCheckPermission is the type-erased
|
|
// submission hierarchy (not checkTxPermission alone, which would
|
|
// reject a matching granular grant). Qualify xrpl:: so the inherited
|
|
// Transactor template is not chosen; it cannot deduce T here. A
|
|
// failed grant is still "not authorized" and becomes
|
|
// tecNO_PERMISSION below — Create is already signed, so do not leak
|
|
// the pre-sign terNO_DELEGATE_PERMISSION.
|
|
if (!*isSigner && proposedTx.isFieldPresent(sfDelegate))
|
|
{
|
|
AccountID const delegateAccount = proposedTx.getAccountID(sfDelegate);
|
|
STTx const proposedStTx{STObject{proposedTx}};
|
|
if (isTesSuccess(xrpl::invokeCheckPermission(ctx.view, proposedStTx)))
|
|
{
|
|
// A grant cannot exist without a funded authorize (DelegateSet
|
|
// uses tecNO_TARGET; AccountDelete of the delegatee removes the
|
|
// Delegate SLE). Do not treat a missing account as a Create-time
|
|
// user error — that would extra-validate the proposed tx. If
|
|
// permission passed anyway, the ledger is corrupt.
|
|
if (!ctx.view.exists(keylet::account(delegateAccount)))
|
|
return tefINTERNAL; // LCOV_EXCL_LINE
|
|
isSigner = isAuthorizedFor(delegateAccount);
|
|
if (!isSigner)
|
|
return isSigner.error();
|
|
}
|
|
}
|
|
|
|
if (!*isSigner)
|
|
{
|
|
JLOG(ctx.j.debug()) << "TransactionProposalCreate: proposer is "
|
|
"not the target account, one of its "
|
|
"signers, or an authorized delegate.";
|
|
return tecNO_PERMISSION;
|
|
}
|
|
}
|
|
|
|
std::uint32_t const ticketSequence = proposedTx.getFieldU32(sfTicketSequence);
|
|
|
|
// The proposal reserves the ticket for as long as it exists (On-Chain
|
|
// Cosigner spec §4.2.1, §5.3.2): a ticket that doesn't exist yet can't be
|
|
// reserved.
|
|
if (!ctx.view.exists(keylet::ticket(target, SeqProxy::rawTicket(ticketSequence))))
|
|
{
|
|
JLOG(ctx.j.debug()) << "TransactionProposalCreate: target ticket "
|
|
"does not exist.";
|
|
return tefNO_TICKET;
|
|
}
|
|
|
|
if (ctx.view.exists(keylet::txProposal(target, ticketSequence)))
|
|
{
|
|
JLOG(ctx.j.debug()) << "TransactionProposalCreate: duplicate proposal.";
|
|
return tecDUPLICATE;
|
|
}
|
|
|
|
return tesSUCCESS;
|
|
}
|
|
|
|
TER
|
|
TransactionProposalCreate::doApply()
|
|
{
|
|
auto const sle = view().peek(keylet::account(accountID_));
|
|
if (!sle)
|
|
return tefINTERNAL; // LCOV_EXCL_LINE
|
|
|
|
auto const proposedTx = ctx_.tx.getFieldObject(sfProposedTransaction);
|
|
std::uint32_t const ownerCount = proposal::proposalOwnerCount(proposedTx);
|
|
|
|
// The proposal holds a full transaction plus its collected signatures, so
|
|
// it reserves more than a typical ledger entry (5 increments; 10 for a
|
|
// proposed Batch).
|
|
if (auto const ret = checkReserve(
|
|
ctx_.getApplyViewContext(),
|
|
sle,
|
|
preFeeBalance_,
|
|
{.ownerCountDelta = static_cast<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
|