diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..f997e6d3abd 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -482,6 +482,9 @@ JSS(previous); // out: Reservations JSS(previous_ledger); // out: LedgerPropose JSS(price); // out: amm_info, AuctionSlot JSS(proof); // in: BookOffers +JSS(proposal); // out: TransactionProposal +JSS(proposal_id); // in/out: TransactionProposal +JSS(proposal_status); // out: TransactionProposal JSS(propose_seq); // out: LedgerPropose JSS(proposers); // out: NetworkOPs, LedgerConsensus JSS(protocol); // out: NetworkOPs, PeerImp @@ -502,6 +505,7 @@ JSS(queue); // in: AccountInfo JSS(queue_data); // out: AccountInfo JSS(queued); // out: SubmitTransaction JSS(queued_duration_us); // +JSS(quorum); // out: TransactionProposal JSS(quote_asset); // in: get_aggregate_price JSS(random); // out: Random JSS(raw_meta); // out: AcceptedLedgerTx @@ -524,7 +528,7 @@ JSS(result); // RPC JSS(ripple_lines); // out: NetworkOPs JSS(ripple_state); // in: LedgerEntr JSS(ripplerpc); // XRPL RPC version -JSS(role); // out: Ping.cpp +JSS(role); // out: Ping.cpp, TransactionProposal JSS(rpc); // JSS(rt_accounts); // in: Subscribe, Unsubscribe JSS(running_duration_us); // @@ -552,8 +556,10 @@ JSS(shares); // out: VaultInfo JSS(signature); // out: NetworkOPs, ChannelAuthorize JSS(signature_target); // in: TransactionSign JSS(signature_verified); // out: ChannelVerify +JSS(signed_weight); // out: TransactionProposal JSS(signing_key); // out: NetworkOPs JSS(signing_keys); // out: ValidatorList +JSS(signing_status); // out: TransactionProposal JSS(signing_time); // out: NetworkOPs JSS(signer_lists); // in/out: AccountInfo JSS(size); // out: get_aggregate_price diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index d2d45197640..7c136f2eaa0 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -228,6 +228,25 @@ class Transactor static NotTEC checkSign(PreclaimContext const& ctx); + // Whether sigObject's signature fields currently authorize idAccount on + // this view. Public because it answers a pure ledger-state question, so + // read-only callers (e.g. the transaction_proposal RPC reporting a + // proposal's completeness) share one authorization rule with the + // transaction path and the two cannot drift. + static NotTEC + checkSign( + ReadView const& view, + ApplyFlags flags, + std::optional const& parentBatchId, + AccountID const& idAccount, + STObject const& sigObject, + beast::Journal const j, + // A batch may carry an inner from an account that an earlier inner + // creates, so the signer account need not exist yet; when it does not, + // only its own master key may authorize it. Normal transactions require + // the account to already exist. + bool permitUncreatedAccount = false); + // Returns the fee in fee units, not scaled for load. static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx); @@ -418,20 +437,6 @@ class Transactor static XRPAmount calculateOwnerReserveFee(ReadView const& view, STTx const& tx); - static NotTEC - checkSign( - ReadView const& view, - ApplyFlags flags, - std::optional const& parentBatchId, - AccountID const& idAccount, - STObject const& sigObject, - beast::Journal const j, - // A batch may carry an inner from an account that an earlier inner - // creates, so the signer account need not exist yet; when it does not, - // only its own master key may authorize it. Normal transactions require - // the account to already exist. - bool permitUncreatedAccount = false); - // Base class always returns true static bool checkExtraFeatures(PreflightContext const& ctx); diff --git a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h index 8ca1f88fddb..e08de4edebe 100644 --- a/include/xrpl/tx/transactors/proposal/ProposalHelpers.h +++ b/include/xrpl/tx/transactors/proposal/ProposalHelpers.h @@ -1,10 +1,18 @@ #pragma once +#include +#include +#include +#include #include +#include #include +#include #include #include +#include +#include namespace xrpl::proposal { @@ -71,4 +79,95 @@ proposalOwnerCount(STObject const& proposedTx) : kProposalOwnerCount; } +/** + * Delete a TransactionProposal ledger entry. + * + * Removes the entry from its Owner's directory, releases the reserve the + * proposal holds against the Owner, and erases the entry. Shared by every + * deletion path the spec defines (XLS-0103 §4.5): automatic cleanup when the + * proposed transaction's TicketSequence is consumed, and the + * TransactionProposalCancel / TransactionProposalSign cleanup paths once + * those transactions exist. + * + * A TransactionProposal cannot carry a reserve sponsor today (its type is + * not sponsorship-supported), so the release always lands on the Owner; it + * goes through decreaseOwnerCountForObject regardless, matching ticketDelete, + * so it would follow an sfSponsor recorded on the entry if the type ever + * becomes sponsorable. + * + * @param view The apply view for making changes + * @param sleProposal The TransactionProposal ledger entry to delete + * @param j Journal for logging + * @return tesSUCCESS, or tefBAD_LEDGER if the ledger contradicts the entry + */ +TER +deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j); + +/** + * The role in which an account's authorization is required on a proposal. + */ +enum class SignerRole : std::uint8_t { + account, ///< the proposed transaction's initiator (Account, or Delegate if present) + batchParticipant, ///< an inner transaction's initiator in a proposed Batch + counterparty, ///< a Counterparty of the proposed transaction or of an inner + sponsor, ///< a co-signing Sponsor of the proposed transaction or of an inner +}; + +/** + * One required authorization on a proposal and whether the signatures + * collected so far currently satisfy it. + */ +struct SignerStatus +{ + AccountID account; + SignerRole role; + /// Whether the collected signature material authorizes `account` on the + /// evaluated ledger (same rule the transaction path applies at preclaim). + bool satisfied = false; + /// Multi-signature progress: the weight the collected Signers entries + /// carry against `account`'s live SignerList. Present only when the + /// collected signature object holds a Signers array. + std::optional signedWeight; + /// `account`'s live SignerQuorum. Present only when the account has a + /// SignerList on the evaluated ledger. + std::optional quorum; +}; + +/** + * Completeness state of a proposal (XLS-0103 §8.1.2). Terminal-first: an + * expired proposal reports expired even when fully signed. + */ +enum class ProposalState : std::uint8_t { pending, complete, expired }; + +/** + * A proposal's completeness state plus the per-account detail it derives + * from. + */ +struct ProposalStatus +{ + ProposalState state = ProposalState::pending; + std::vector signers; +}; + +/** + * Evaluate how far a TransactionProposal's collected signatures are from a + * submittable transaction on the given ledger. + * + * Signatures stored on the proposal were cryptographically verified when they + * were appended, so this only re-checks their authorization against live + * ledger state (SignerList membership and quorum, regular-key rotation, + * disabled master keys), mirroring what Transactor::checkSign would decide at + * submission time. For a proposed Batch, each inner initiator, counterparty, + * and co-signing sponsor other than the outer account is a separate required + * authorization collected through BatchSigners, mirroring + * Batch::preflightSigValidated. A LoanSet without an explicit Counterparty + * requires the owner of its LoanBroker instead, mirroring LoanSet::checkSign. + * + * @param view The ledger to evaluate against. + * @param sleProposal A TransactionProposal ledger entry of that ledger. + * @param j Journal for logging. + */ +ProposalStatus +evaluateProposal(ReadView const& view, SLE const& sleProposal, beast::Journal j); + } // namespace xrpl::proposal diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 4b562692d75..e5d1a64ac4d 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include @@ -864,8 +865,25 @@ Transactor::ticketDelete( // Update the Ticket owner's reserve. decreaseOwnerCountForObject(view, sleAccount, sleTicket, 1, j); + std::uint32_t const ticketSeq{(*sleTicket)[sfTicketSequence]}; + // Remove Ticket from ledger. view.erase(sleTicket); + + // Once the Ticket is gone, a TransactionProposal keyed to it can never + // execute: its proposed transaction would fail with tefNO_TICKET. Clean + // up the stale proposal and release its Owner's reserve (XLS-0103 §4.5). + // This runs both when the proposal's own completed transaction consumes + // the ticket and when the account spends the ticket on something else. + if (view.rules().enabled(featureCosign)) + { + if (auto const sleProposal = view.peek(keylet::txProposal(account, ticketSeq))) + { + if (TER const ter = proposal::deleteProposal(view, sleProposal, j); !isTesSuccess(ter)) + return ter; // LCOV_EXCL_LINE + } + } + return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp new file mode 100644 index 00000000000..3f05079be3b --- /dev/null +++ b/src/libxrpl/tx/transactors/proposal/ProposalHelpers.cpp @@ -0,0 +1,343 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::proposal { + +TER +deleteProposal(ApplyView& view, SLE::pointer const& sleProposal, beast::Journal j) +{ + // view carries no null contract (a reference), but the two parameters are + // bound to each other: sleProposal must be a live entry of this same + // view, since the directory removal, owner-root peek, and erase below all + // mutate that view assuming they see the entry's state. + XRPL_ASSERT( + sleProposal && sleProposal->getType() == ltTRANSACTION_PROPOSAL && + view.exists(Keylet{ltTRANSACTION_PROPOSAL, sleProposal->key()}), + "xrpl::proposal::deleteProposal : valid proposal sle of this view"); + + AccountID const owner = sleProposal->getAccountID(sfOwner); + + std::uint64_t const page{(*sleProposal)[sfOwnerNode]}; + if (!view.dirRemove(keylet::ownerDir(owner), page, sleProposal->key(), true)) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "Unable to delete TransactionProposal from owner."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + auto const sleOwner = view.peek(keylet::account(owner)); + if (!sleOwner) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "Could not find TransactionProposal owner account root."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + // Release the reserve against the Owner or, if the entry carries a + // reserve sponsor, against that sponsor. + decreaseOwnerCountForObject( + view, + sleOwner, + sleProposal, + proposalOwnerCount(sleProposal->getFieldObject(sfProposedTransaction)), + j); + + view.erase(sleProposal); + return tesSUCCESS; +} + +namespace { + +/** + * Whether the signature object is well-formed enough to hand to + * Transactor::checkSign. Bare or partially-filled objects (a proposal + * accumulates signatures over time) are simply "not signed yet". Shapes the + * transaction path asserts rather than checks — a single signature without a + * SigningPubKey, a Signers entry with an empty one, TxnSignature alongside + * Signers — must be treated as unauthorized instead of reaching those + * assertions from a read-only path. + */ +bool +authorizationCheckable(STObject const& sigObject) +{ + bool const hasTxnSignature = sigObject.isFieldPresent(sfTxnSignature); + if (sigObject.isFieldPresent(sfSigners)) + { + if (hasTxnSignature) + return false; + auto const& signers = sigObject.getFieldArray(sfSigners); + return !signers.empty() && std::ranges::all_of(signers, [](STObject const& signer) { + return signer.isFieldPresent(sfAccount) && signer.isFieldPresent(sfSigningPubKey) && + !signer.getFieldVL(sfSigningPubKey).empty(); + }); + } + return hasTxnSignature && sigObject.isFieldPresent(sfSigningPubKey) && + !sigObject.getFieldVL(sfSigningPubKey).empty(); +} + +/** + * Report multi-signature progress: the account's live SignerQuorum, and the + * weight the collected Signers entries hold against that live list. Entries + * that are no longer on the list contribute nothing, matching how they would + * fare at submission time. + */ +void +reportMultiSignProgress( + ReadView const& view, + AccountID const& account, + STObject const* sigObject, + SignerStatus& status, + beast::Journal j) +{ + auto const sleList = view.read(keylet::signerList(account)); + if (sleList) + status.quorum = sleList->getFieldU32(sfSignerQuorum); + + if (!sigObject || !sigObject->isFieldPresent(sfSigners)) + return; + + std::uint32_t weight = 0; + if (sleList) + { + if (auto const entries = SignerEntries::deserialize(*sleList, j, "ledger")) + { + for (STObject const& signer : sigObject->getFieldArray(sfSigners)) + { + AccountID const id = signer.getAccountID(sfAccount); + auto const it = std::ranges::find_if( + *entries, [&id](auto const& entry) { return entry.account == id; }); + if (it != entries->end()) + weight += it->weight; + } + } + } + status.signedWeight = weight; +} + +/** + * Whether the signature material collected for one account currently + * authorizes it, plus its multi-signature progress. sigObject is null when no + * signature has been collected for the account yet. + */ +SignerStatus +evaluateAuthorization( + ReadView const& view, + AccountID const& account, + SignerRole role, + STObject const* sigObject, + bool permitUncreatedAccount, + beast::Journal j) +{ + SignerStatus status{.account = account, .role = role}; + + // Malformed on-ledger signature material must degrade to "not satisfied" + // rather than fail the caller (an RPC), so every field access on + // sigObject stays inside this try. + try + { + reportMultiSignProgress(view, account, sigObject, status, j); + + if (sigObject && authorizationCheckable(*sigObject)) + { + // The same authorization rule the transaction path applies at + // preclaim; crypto validity was already checked when the + // signature was appended to the proposal. + status.satisfied = isTesSuccess(Transactor::checkSign( + view, TapNone, std::nullopt, account, *sigObject, j, permitUncreatedAccount)); + } + } + catch (std::exception const& e) + { + JLOG(j.warn()) << "evaluateAuthorization: signature material for " << toBase58(account) + << " is malformed: " << e.what(); + status.satisfied = false; + } + + return status; +} + +} // namespace + +ProposalStatus +evaluateProposal(ReadView const& view, SLE const& sleProposal, beast::Journal j) +{ + XRPL_ASSERT( + sleProposal.getType() == ltTRANSACTION_PROPOSAL, + "xrpl::proposal::evaluateProposal : a TransactionProposal entry"); + + ProposalStatus result; + STObject const proposedTx = sleProposal.getFieldObject(sfProposedTransaction); + + AccountID const initiator = proposedTx.isFieldPresent(sfDelegate) + ? proposedTx.getAccountID(sfDelegate) + : proposedTx.getAccountID(sfAccount); + + // The initiator's authorization lives in the top-level signature fields. + // Evaluate them without the sponsor's signature: that is a separate + // authorization reported on its own row, and Transactor::checkSign would + // otherwise fold its validity into the initiator's. + { + STObject topLevel = proposedTx; + if (topLevel.isFieldPresent(sfSponsorSignature)) + topLevel.makeFieldAbsent(sfSponsorSignature); + result.signers.push_back(evaluateAuthorization( + view, initiator, SignerRole::account, &topLevel, /*permitUncreatedAccount=*/false, j)); + } + + // The required auxiliary co-signer, who signs through + // CounterpartySignature: the explicit Counterparty or — for a LoanSet, + // the only type with an implicit one — the owner of its LoanBroker + // (mirrors LoanSet::checkSign). The implicit rule is keyed on the + // transaction type, not on sfLoanBrokerID: the LoanBroker* types carry + // that field too but require no counterparty. + std::optional counterparty = proposedTx[~sfCounterparty]; + if (!counterparty && proposedTx.getFieldU16(sfTransactionType) == ttLOAN_SET && + proposedTx.isFieldPresent(sfLoanBrokerID)) + { + if (auto const broker = + view.read(keylet::loanBroker(proposedTx.getFieldH256(sfLoanBrokerID)))) + counterparty = broker->at(sfOwner); + } + if (counterparty) + { + std::optional const sig = proposedTx.isFieldPresent(sfCounterpartySignature) + ? std::optional(proposedTx.getFieldObject(sfCounterpartySignature)) + : std::nullopt; + result.signers.push_back(evaluateAuthorization( + view, + *counterparty, + SignerRole::counterparty, + sig ? &*sig : nullptr, + /*permitUncreatedAccount=*/false, + j)); + } + + // A Sponsor either co-signs through SponsorSignature or is pre-authorized + // by an on-ledger Sponsorship entry whose flags do not demand a signature + // for what this transaction sponsors (mirrors Transactor::checkSponsor). + if (proposedTx.isFieldPresent(sfSponsor)) + { + AccountID const sponsor = proposedTx.getAccountID(sfSponsor); + std::optional const sig = proposedTx.isFieldPresent(sfSponsorSignature) + ? std::optional(proposedTx.getFieldObject(sfSponsorSignature)) + : std::nullopt; + auto status = evaluateAuthorization( + view, sponsor, SignerRole::sponsor, sig ? &*sig : nullptr, false, j); + // The pre-authorization fallback applies only while no + // SponsorSignature has been collected: once the field exists, + // Transactor::checkSign validates it unconditionally at submission, + // so a failing (e.g. stale-key) signature must not be rescued here. + if (!status.satisfied && !sig) + { + std::uint32_t const sponsorFlags = + proposedTx.isFieldPresent(sfSponsorFlags) ? proposedTx.getFieldU32(sfSponsorFlags) : 0; + if (auto const sleSponsorship = view.read(keylet::sponsorship(sponsor, initiator))) + { + bool const feeNeedsSig = ((sponsorFlags & spfSponsorFee) != 0u) && + sleSponsorship->isFlag(lsfSponsorshipRequireSignForFee); + bool const reserveNeedsSig = ((sponsorFlags & spfSponsorReserve) != 0u) && + sleSponsorship->isFlag(lsfSponsorshipRequireSignForReserve); + status.satisfied = !feeNeedsSig && !reserveNeedsSig; + } + } + result.signers.push_back(std::move(status)); + } + + // A proposed Batch needs each inner initiator, counterparty, and + // co-signing sponsor other than the outer account to authorize through a + // BatchSigners entry (mirrors Batch::preflightSigValidated). BatchSigners + // entries that no required account matches are ignored here: the Sign + // transaction never stores one, and completeness cannot come from them. + if (proposedTx.getFieldU16(sfTransactionType) == ttBATCH) + { + AccountID const outerAccount = proposedTx.getAccountID(sfAccount); + + std::vector> required; + auto const addRequired = [&outerAccount, &required](AccountID const& id, SignerRole role) { + if (id == outerAccount) + return; + if (std::ranges::none_of( + required, [&id](auto const& entry) { return entry.first == id; })) + required.emplace_back(id, role); + }; + for (STObject const& rb : proposedTx.getFieldArray(sfRawTransactions)) + { + addRequired( + rb.isFieldPresent(sfDelegate) ? rb.getAccountID(sfDelegate) + : rb.getAccountID(sfAccount), + SignerRole::batchParticipant); + if (auto const counterparty = rb[~sfCounterparty]) + addRequired(*counterparty, SignerRole::counterparty); + if (rb.isFieldPresent(sfSponsor) && rb.isFieldPresent(sfSponsorSignature)) + addRequired(rb.getAccountID(sfSponsor), SignerRole::sponsor); + } + + STArray const* const batchSigners = proposedTx.isFieldPresent(sfBatchSigners) + ? &proposedTx.getFieldArray(sfBatchSigners) + : nullptr; + auto const findBatchSigner = [batchSigners](AccountID const& id) -> STObject const* { + if (!batchSigners) + return nullptr; + auto const it = std::ranges::find_if(*batchSigners, [&id](STObject const& signer) { + return signer.getAccountID(sfAccount) == id; + }); + return it != batchSigners->end() ? &*it : nullptr; + }; + + for (auto const& [id, role] : required) + { + // permitUncreatedAccount: an earlier inner transaction may create + // the signer's account, so authorization by its own master key + // must count (mirrors Batch::checkBatchSign). + result.signers.push_back(evaluateAuthorization( + view, id, role, findBatchSigner(id), /*permitUncreatedAccount=*/true, j)); + } + } + + // Terminal-first (XLS-0103 §8.1.2): a proposal past its Expiration or its + // transaction's LastLedgerSequence reports expired even if fully signed. + // An open view can still include the transaction itself; a closed one + // only in a successor, matching the tefMAX_LEDGER rule (seq > LLS fails). + std::uint32_t const earliestSeq = view.seq() + (view.open() ? 0 : 1); + bool const expired = hasExpired(view, sleProposal[~sfExpiration]) || + (proposedTx.isFieldPresent(sfLastLedgerSequence) && + proposedTx.getFieldU32(sfLastLedgerSequence) < earliestSeq); + + if (expired) + result.state = ProposalState::expired; + else if (std::ranges::all_of(result.signers, [](auto const& s) { return s.satisfied; })) + result.state = ProposalState::complete; + else + result.state = ProposalState::pending; + + return result; +} + +} // namespace xrpl::proposal diff --git a/src/test/app/TransactionProposalAutoDelete_test.cpp b/src/test/app/TransactionProposalAutoDelete_test.cpp new file mode 100644 index 00000000000..dcbed571516 --- /dev/null +++ b/src/test/app/TransactionProposalAutoDelete_test.cpp @@ -0,0 +1,815 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +// Automatic cleanup of a TransactionProposal when the proposed transaction's +// TicketSequence is consumed (XLS-0103 §4.5): any transaction of the target +// account that spends the ticket makes the proposal permanently unexecutable, +// so applying that transaction deletes the proposal and releases the reserve +// it holds against its Owner. +struct TransactionProposalAutoDelete_test : public beast::unit_test::Suite +{ + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env const& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + + // Consuming the proposed transaction's ticket deletes the proposal and + // refunds the Owner's reserve, whether the ticket is spent on the + // proposal's own transaction or on something unrelated. Consuming a + // different ticket, or the target's live sequence, leaves it untouched. + void + testTicketSpendDeletesProposal(FeatureBitset features) + { + testcase("ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; // the proposer + Account const target{"target"}; // the account the proposals are for + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 2)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq + 1), expiration)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + // Each proposal reserves several owner increments; the target owns + // only its two Tickets. + BEAST_EXPECT(ownerCount(env, alice) == 2 * proposal::kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, target) == 2); + + // A sequence-based transaction of the target consumes no ticket, so + // both proposals survive. + env(noop(target)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + + // The proposal's own transaction runs: the target submits the very + // payment the first proposal holds, spending its ticket. That is the + // completed-proposal case of XLS-0103 §6.5 — execution goes through + // the ordinary path and the consumed ticket auto-deletes the proposal. + env(pay(target, bob, XRP(1)), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + BEAST_EXPECT(ownerCount(env, target) == 1); + + // The target spends the second ticket on something unrelated to the + // proposal. The proposal can then never execute, so it is deleted all + // the same. + env(noop(target), ticket::Use(ticketSeq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + + // Directory integrity: deletion must have unlinked the proposals from + // alice's owner directory, not just erased the entries. A dangling + // directory entry would make this AccountDelete fail. + incLgrSeqForAccDel(env, alice); + env(acctdelete(alice, bob), Fee(env.current()->fees().increment)); + env.close(); + BEAST_EXPECT(!env.le(keylet::account(alice.id()))); + } + + // The proposal is keyed by target account and ticket, so another account + // consuming its own ticket of the same numeric sequence must not touch it. + void + testOtherAccountsTicket(FeatureBitset features) + { + testcase("other account's ticket does not delete proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + // target and bob were funded together, so creating one ticket each in + // the same ledger gives their tickets the same numeric sequence. + std::uint32_t const targetTicketSeq = env.seq(target) + 1; + std::uint32_t const bobTicketSeq = env.seq(bob) + 1; + env(ticket::create(target, 1)); + env(ticket::create(bob, 1)); + env.close(); + BEAST_EXPECT(targetTicketSeq == bobTicketSeq); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, targetTicketSeq), expiration)); + env.close(); + + env(noop(bob), ticket::Use(bobTicketSeq)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), targetTicketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + } + + // A ticket is consumed even when its transaction fails with a tec, and + // once consumed the proposal can never execute, so a claimed-fee failure + // cleans up the proposal exactly as a success does. + void + testTecResultStillDeletes(FeatureBitset features) + { + testcase("tec result still deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + // The payment fails but claims a fee, which consumes the ticket. + env(pay(target, bob, XRP(1'000'000)), ticket::Use(ticketSeq), Ter(tecUNFUNDED_PAYMENT)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // A proposed Batch reserves more increments than an ordinary proposal; + // deletion must release exactly what creation reserved. + void + testBatchProposalReserveRefund(FeatureBitset features) + { + testcase("batch proposal refunds its larger reserve"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto inner = [&](std::uint32_t seq) { + json::Value tx = pay(target, bob, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = ticketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 0, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = inner(env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(env.seq(target) + 1); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, proposedTx, expiration)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kBatchProposalOwnerCount); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // An inner Batch transaction consumes its ticket through the same path as + // a standalone transaction, so it too cleans up a proposal keyed to it. + void + testInnerBatchTicketSpend(FeatureBitset features) + { + testcase("inner batch transaction ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + // The target submits a Batch whose second inner transaction spends + // the proposal's ticket. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfAllOrNothing), + batch::Inner(pay(target, bob, XRP(1)), seq + 1), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // Deleting the target account removes its Tickets, after which a proposal + // keyed to one of them can never execute. The proposal is owned by the + // proposer — never by the deleted account, for which it would have been a + // deletion blocker — so it is cleaned up and the proposer refunded. + void + testTargetAccountDeleted(FeatureBitset features) + { + testcase("deleting the target account deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + // Expire far enough out that the proposal is still live when the + // account becomes deletable. + std::uint32_t const expiration = (env.now() + 3600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + + incLgrSeqForAccDel(env, target); + env(acctdelete(target, bob), Fee(env.current()->fees().increment)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::account(target.id()))); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // The proposer may be the target itself. Then the ticket bookkeeping and + // the proposal's reserve release both land on the same account-root SLE + // within one ticketDelete call, so this pins the aliasing case. + void + testProposerIsTarget(FeatureBitset features) + { + testcase("proposer is the target account"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(alice) + 1; + env(ticket::create(alice, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, alice, bob, ticketSeq), expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(alice.id(), ticketSeq)); + BEAST_EXPECT(sle && sle->getAccountID(sfOwner) == alice.id()); + // One Ticket plus the proposal's increments, all against alice. + BEAST_EXPECT(ownerCount(env, alice) == 1 + proposal::kProposalOwnerCount); + + env(noop(alice), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(alice.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // A TransactionProposal blocks its Owner's account deletion (XLS-0103 + // §4.6). Beyond the spec requirement, this blocker is what guarantees the + // AccountDelete ticket sweep never deletes a proposal out of the very + // owner directory it is iterating: any proposal reached through a swept + // ticket is necessarily owned by an account other than the one being + // deleted. + void + testProposalBlocksOwnerAccountDelete(FeatureBitset features) + { + testcase("proposal blocks its owner's account deletion"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; // proposer, tries to delete itself + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 3600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + incLgrSeqForAccDel(env, alice); + env(acctdelete(alice, bob), Fee(env.current()->fees().increment), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + BEAST_EXPECT(env.le(keylet::account(alice.id()))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + } + + // A tfAllOrNothing Batch whose inner transaction spends the proposal's + // ticket but whose sibling fails is discarded entirely: the ticket + // survives, so the proposal — still executable — must survive with it. + void + testBatchDiscardKeepsProposal(FeatureBitset features) + { + testcase("discarded batch keeps proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Inner #1 (ticket-based) would succeed; inner #2 fails, so + // tfAllOrNothing discards every inner change, ticket included. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfAllOrNothing), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1'000'000)), seq + 1)); + env.close(); + + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + // The ticket survived the discard. + BEAST_EXPECT(ownerCount(env, target) == 1); + + // The resurrected ticket still triggers cleanup when it is finally + // consumed for real. + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // Under tfIndependent a failed inner transaction is still applied as a + // claimed-fee result, durably consuming its ticket — so the proposal + // must be deleted even though the inner transaction failed. + void + testBatchInnerTecStillDeletes(FeatureBitset features) + { + testcase("failed independent inner transaction still deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 2); + env(batch::outer(target, seq, batchFee, tfIndependent), + batch::Inner(pay(target, bob, XRP(1'000'000)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1)), seq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // tfUntilFailure applies inner transactions up to the first failure: + // tickets consumed before the break delete their proposals; tickets never + // reached keep theirs. + void + testBatchPartialConsumption(FeatureBitset features) + { + testcase("partially applied batch deletes only consumed tickets' proposals"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 2)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq + 1), expiration)); + env.close(); + BEAST_EXPECT(ownerCount(env, alice) == 2 * proposal::kProposalOwnerCount); + + // Inner #1 consumes the first ticket, inner #2 fails and stops the + // batch, inner #3 (second ticket) is never attempted. + auto const seq = env.seq(target); + auto const batchFee = batch::calcBatchFee(env, 0, 3); + env(batch::outer(target, seq, batchFee, tfUntilFailure), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq), + batch::Inner(pay(target, bob, XRP(1'000'000)), seq + 1), + batch::Inner(pay(target, bob, XRP(1)), 0, ticketSeq + 1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq + 1))); + BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); + // The unreached ticket survives. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A delegate (sfDelegate) submits and pays for the transaction, but the + // ticket consumed belongs to the delegating account — the hook must key + // on the ticket owner, not on the submitter or fee payer. + void + testDelegatedTicketSpend(FeatureBitset features) + { + testcase("delegated transaction ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const marty{"marty"}; // target's delegate + env.fund(XRP(10000), alice, target, bob, marty); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env(delegate::set(target, marty, {"Payment"})); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(pay(target, bob, XRP(1)), delegate::As(marty), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + // The target's one remaining object is the Delegate entry itself. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A fee-sponsored transaction charges its fee to the sponsor while the + // account's own ticket is consumed — the hook must follow the ticket. + void + testFeeSponsoredTicketSpend(FeatureBitset features) + { + testcase("fee-sponsored transaction ticket spend deletes proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const payer{"sponsor"}; + env.fund(XRP(10000), alice, target, bob, payer); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(pay(target, bob, XRP(1)), + ticket::Use(ticketSeq), + Fee(XRP(1)), + sponsor::As(payer, spfSponsorFee), + Sig(sfSponsorSignature, payer)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, target) == 0); + } + + // A TicketCreate submitted using a ticket consumes it like any other + // transaction, then mints fresh tickets; only the consumed ticket's + // proposal goes away. + void + testTicketCreateViaTicket(FeatureBitset features) + { + testcase("ticket-funded TicketCreate deletes consumed ticket's proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + env(ticket::create(target, 1), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + // The freshly minted ticket is the target's only object. + BEAST_EXPECT(ownerCount(env, target) == 1); + } + + // A proposal that is already terminal (Expiration passed) is still + // deleted when its ticket is consumed: the hook deliberately has no + // terminal-state check, and XLS-0103 §4.5 makes automatic cleanup + // unconditional. + void + testExpiredProposalStillDeleted(FeatureBitset features) + { + testcase("expired proposal still deleted on ticket spend"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 60s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Sail past the expiration: the proposal is terminal but stays in + // ledger state (nothing cleans up on expiry by itself). + env.close(env.now() + 120s); + BEAST_EXPECT(env.le(keylet::txProposal(target.id(), ticketSeq))); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 0); + } + + // With more than 32 owned objects the proposer's directory spans multiple + // pages, so deletion must honor the sfOwnerNode page hint stored at + // creation rather than assume the root page. + void + testMultiPageOwnerDirectory(FeatureBitset features) + { + testcase("proposal on a non-root owner directory page"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + // Fill alice's owner directory past one page (32 entries) before the + // proposal is created. + env(ticket::create(alice, 40)); + env.close(); + + std::uint32_t const expiration = (env.now() + 600s).time_since_epoch().count(); + + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const sle = env.le(keylet::txProposal(target.id(), ticketSeq)); + BEAST_EXPECT(sle); + if (!sle) + return; + // The shape under test: the proposal must have landed off the root + // directory page. + BEAST_EXPECT((*sle)[sfOwnerNode] != 0); + BEAST_EXPECT(ownerCount(env, alice) == 40 + proposal::kProposalOwnerCount); + + env(noop(target), ticket::Use(ticketSeq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::txProposal(target.id(), ticketSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 40); + } + + // No sponsored-proposal case: a TransactionProposal cannot carry a + // reserve sponsor today — ttTRANSACTION_PROPOSAL_CREATE is not in the v1 + // reserve-sponsorship allow-list (isReserveSponsorAllowed) and + // ltTRANSACTION_PROPOSAL is not transferable to a sponsor + // (isLedgerEntrySupportedBySponsorship). deleteProposal releases the + // reserve through decreaseOwnerCountForObject, so if those lists ever + // grow, deletion follows the sfSponsor recorded on the entry. + + void + run() override + { + using namespace jtx; + testTicketSpendDeletesProposal(testableAmendments()); + testOtherAccountsTicket(testableAmendments()); + testTecResultStillDeletes(testableAmendments()); + testBatchProposalReserveRefund(testableAmendments()); + testInnerBatchTicketSpend(testableAmendments()); + testTargetAccountDeleted(testableAmendments()); + testProposerIsTarget(testableAmendments()); + testProposalBlocksOwnerAccountDelete(testableAmendments()); + testBatchDiscardKeepsProposal(testableAmendments()); + testBatchInnerTecStillDeletes(testableAmendments()); + testBatchPartialConsumption(testableAmendments()); + testDelegatedTicketSpend(testableAmendments()); + testFeeSponsoredTicketSpend(testableAmendments()); + testTicketCreateViaTicket(testableAmendments()); + testExpiredProposalStillDeleted(testableAmendments()); + testMultiPageOwnerDirectory(testableAmendments()); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalAutoDelete, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/rpc/TransactionProposal_test.cpp b/src/test/rpc/TransactionProposal_test.cpp new file mode 100644 index 00000000000..c821a524eb3 --- /dev/null +++ b/src/test/rpc/TransactionProposal_test.cpp @@ -0,0 +1,936 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +struct TransactionProposalRPC_test : public beast::unit_test::Suite +{ + static constexpr auto kSigned = "signed"; + + // A TransactionProposalCreate carrying an unsigned proposed transaction. + static json::Value + proposalCreate( + jtx::Account const& proposer, + json::Value const& proposedTx, + std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::Account] = proposer.human(); + jv[sfProposedTransaction.getJsonName()] = proposedTx; + jv[sfExpiration.getJsonName()] = expiration; + return jv; + } + + // A proposed transaction in the form the ledger stores it: unsigned, + // ticket-based, with the fee the target account will pay fixed now. + static json::Value + unsignedPayload( + jtx::Env& env, + jtx::Account const& target, + jtx::Account const& dest, + std::uint32_t ticketSeq) + { + json::Value tx = jtx::pay(target, dest, jtx::XRP(1)); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return tx; + } + + // Parse a TransactionProposalCreate through the transaction machinery + // (without submitting it) and extract the proposed transaction as the + // typed STObject the ledger would store. + static STObject + parsedPayload(jtx::Env& env, jtx::Account const& proposer, json::Value const& proposedTx) + { + auto const jt = + env.jt(proposalCreate(proposer, proposedTx, (env.now() + std::chrono::seconds(1000)).time_since_epoch().count())); + return jt.stx->getFieldObject(sfProposedTransaction); + } + + // A TransactionProposal ledger entry as TransactionProposalSign would + // leave it after appending signatures: the evaluator only reads + // ProposedTransaction and Expiration, everything else is boilerplate. + static std::shared_ptr + makeProposalSLE( + jtx::Account const& owner, + STObject const& proposedTx, + std::uint32_t expiration) + { + auto const target = proposedTx.getAccountID(sfAccount); + auto const ticketSeq = proposedTx.getFieldU32(sfTicketSequence); + auto sle = std::make_shared(keylet::txProposal(target, ticketSeq)); + sle->setAccountID(sfOwner, owner.id()); + sle->setFieldObject(sfProposedTransaction, proposedTx); + sle->setFieldU32(sfExpiration, expiration); + sle->setFieldU64(sfOwnerNode, 0); + sle->setFieldH256(sfPreviousTxnID, uint256{}); + sle->setFieldU32(sfPreviousTxnLgrSeq, 0); + return sle; + } + + static std::uint32_t + farFuture(jtx::Env& env) + { + return (env.now() + std::chrono::seconds(1000)).time_since_epoch().count(); + } + + // Single-signature material: the crypto was verified when the signature + // was appended on-ledger, so the evaluator only inspects the public key. + static void + singleSign(STObject& obj, PublicKey const& pk) + { + obj.setFieldVL(sfSigningPubKey, pk.slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + } + + static STObject + makeSignerEntry(jtx::Account const& acct) + { + STObject obj(sfSigner); + obj.setAccountID(sfAccount, acct.id()); + obj.setFieldVL(sfSigningPubKey, acct.pk().slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + return obj; + } + + // Multi-signature material: Signers sorted by account ID, as the ledger + // stores them. + static void + multiSign(STObject& obj, std::vector accounts) + { + std::sort(accounts.begin(), accounts.end(), [](auto const& a, auto const& b) { + return a.id() < b.id(); + }); + STArray signers(sfSigners); + for (auto const& acct : accounts) + signers.push_back(makeSignerEntry(acct)); + obj.setFieldArray(sfSigners, signers); + } + + static STObject + makeBatchSigner(jtx::Account const& acct) + { + STObject obj(sfBatchSigner); + obj.setAccountID(sfAccount, acct.id()); + obj.setFieldVL(sfSigningPubKey, acct.pk().slice()); + obj.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + return obj; + } + + static proposal::ProposalStatus + evaluate(jtx::Env& env, std::shared_ptr const& sle) + { + return proposal::evaluateProposal(*env.current(), *sle, env.journal); + } + + static proposal::SignerStatus const* + findSigner(proposal::ProposalStatus const& status, AccountID const& id) + { + for (auto const& signer : status.signers) + if (signer.account == id) + return &signer; + return nullptr; + } + + void + testMalformedRequests(FeatureBitset features) + { + testcase("malformed requests"); + + using namespace jtx; + Env env{*this, features}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + auto const rpc = [&](json::Value const& params) { + return env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + }; + + // No addressing fields at all. + { + json::Value params{json::ValueType::Object}; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // proposal_id is not hex. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = "not-hex"; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedRequest"); + } + + // proposal_id combined with account/ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(uint256{1}); + params[jss::account] = alice.human(); + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // account without ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = alice.human(); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + } + + // Malformed account. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = "rNotAnAccount!!!"; + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedAddress"); + } + + // account of a wrong JSON type must be a parameter error, not an + // internal one. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = 42; + params[jss::ticket_seq] = 1; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedAddress"); + } + + // ticket_seq that does not parse as a number. (Numeric strings are + // accepted, matching ledger_entry's transaction_proposal addressing.) + { + json::Value params{json::ValueType::Object}; + params[jss::account] = alice.human(); + params[jss::ticket_seq] = "one"; + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "malformedRequest"); + } + + // Well-formed but nonexistent. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(uint256{42}); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "entryNotFound"); + } + + // An index that names a different ledger entry type: reads as absent, + // not as a proposal. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(keylet::account(alice.id()).key); + auto const jrr = rpc(params); + BEAST_EXPECT(jrr[jss::error] == "entryNotFound"); + } + } + + void + testPendingUnsigned(FeatureBitset features) + { + testcase("pending unsigned proposal via RPC"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 100s).time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + auto const proposalKey = keylet::txProposal(target.id(), ticketSeq).key; + + auto const check = [&](json::Value const& jrr) { + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + BEAST_EXPECT(jrr[jss::proposal_id] == to_string(proposalKey)); + BEAST_EXPECT( + jrr[jss::proposal][sfOwner.getJsonName()] == alice.human()); + auto const& signers = jrr[jss::signing_status]; + BEAST_EXPECT(signers.isArray() && signers.size() == 1); + BEAST_EXPECT(signers[0u][jss::account] == target.human()); + BEAST_EXPECT(signers[0u][jss::role] == "account"); + BEAST_EXPECT(signers[0u][kSigned] == false); + // No SignerList: no quorum to report. + BEAST_EXPECT(!signers[0u].isMember(jss::quorum)); + }; + + // By proposal_id. + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(proposalKey); + check(env.rpc("json", "transaction_proposal", to_string(params))[jss::result]); + } + + // By account + ticket_seq. + { + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + check(env.rpc("json", "transaction_proposal", to_string(params))[jss::result]); + } + + // Once the target sets a SignerList, its live quorum is reported. + env(signers(target, 2, {{bob, 1}, {alice, 1}})); + env.close(); + { + json::Value params{json::ValueType::Object}; + params[jss::proposal_id] = to_string(proposalKey); + auto const jrr = env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + auto const& signerStatus = jrr[jss::signing_status]; + BEAST_EXPECT(signerStatus[0u][jss::quorum] == 2); + } + } + + void + testExpiredStates(FeatureBitset features) + { + testcase("expired proposal"); + + using namespace jtx; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, target, bob); + env.close(); + + // Expiration reached: terminal even though nothing else changed. + { + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + std::uint32_t const expiration = (env.now() + 60s).time_since_epoch().count(); + env(proposalCreate(alice, unsignedPayload(env, target, bob, ticketSeq), expiration)); + env.close(); + + // Pass the expiration time. + env.close(env.now() + 120s); + + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + auto const jrr = env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "expired"); + } + + // The proposed transaction's own LastLedgerSequence has passed: the + // transaction can never enter a ledger, so the proposal is terminal + // even though its Expiration is still far away. + { + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value payload = unsignedPayload(env, target, bob, ticketSeq); + std::uint32_t const lastLedgerSeq = env.current()->seq() + 2; + payload[jss::LastLedgerSequence] = lastLedgerSeq; + + std::uint32_t const expiration = (env.now() + 1000s).time_since_epoch().count(); + env(proposalCreate(alice, payload, expiration)); + env.close(); + env.close(); + + json::Value params{json::ValueType::Object}; + params[jss::account] = target.human(); + params[jss::ticket_seq] = ticketSeq; + + // Boundary: the RPC's default ledger is the open ledger, which + // the proposed transaction can still enter when its + // LastLedgerSequence equals that ledger's sequence. + BEAST_EXPECT(env.current()->seq() == lastLedgerSeq); + { + auto const jrr = + env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "pending"); + } + + // One ledger later the bound has passed for good. + env.close(); + { + auto const jrr = + env.rpc("json", "transaction_proposal", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::proposal_status] == "expired"); + } + } + } + + void + testSingleSignAuthorization(FeatureBitset features) + { + testcase("single-signature authorization currency"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const regular{"regular"}; + env.fund(XRP(10000), alice, target, bob); + // The regular key account never exists on ledger; jtx only needs to + // know its keys to sign with them once the RegularKey is set. + env.memoize(regular); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const base = parsedPayload(env, alice, unsignedPayload(env, target, bob, ticketSeq)); + + // Unsigned: pending. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(!status.signers[0].satisfied); + } + + // Master-key signed: complete. + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // A key unrelated to the target signs: not authorized. + { + STObject payload = base; + singleSign(payload, bob.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // Regular-key signed: complete once the key is set... + env(regkey(target, regular)); + env.close(); + { + STObject payload = base; + singleSign(payload, regular.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // ...and the master key still works while enabled... + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // ...but a master signature collected earlier no longer authorizes + // once the master key is disabled: authorization is re-checked + // against live state. + // Disabling the master key must itself be signed with the master key + // (jtx would otherwise sign with the regular key set above). + env(fset(target, asfDisableMaster), Sig(target)); + env.close(); + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // Terminal-first: a fully signed proposal past its expiration reports + // expired, not complete. + { + STObject payload = base; + singleSign(payload, regular.pk()); + std::uint32_t const past = (env.now() - std::chrono::seconds(10)).time_since_epoch().count(); + auto const status = evaluate(env, makeProposalSLE(alice, payload, past)); + BEAST_EXPECT(status.state == proposal::ProposalState::expired); + } + } + + void + testMultiSignAuthorization(FeatureBitset features) + { + testcase("multi-signature quorum against the live SignerList"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const dest{"dest"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + Account const outsider{"outsider"}; + env.fund(XRP(10000), alice, target, dest, bob, carol, dave, outsider); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const base = parsedPayload(env, alice, unsignedPayload(env, target, dest, ticketSeq)); + + // No SignerList yet: collected Signers cannot authorize anything. + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(!status.signers[0].quorum.has_value()); + BEAST_EXPECT(status.signers[0].signedWeight == 0); + } + + env(signers(target, 2, {{bob, 1}, {carol, 1}, {dave, 1}})); + env.close(); + + // One of three signers: quorum not met; progress is reported. + { + STObject payload = base; + multiSign(payload, {bob}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 1); + BEAST_EXPECT(status.signers[0].quorum == 2); + } + + // Two of three: quorum met. + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + BEAST_EXPECT(status.signers[0].signedWeight == 2); + } + + // A signer that is not on the live list poisons the whole set (the + // ordinary submission path rejects it wholesale), and contributes no + // weight. + { + STObject payload = base; + multiSign(payload, {bob, carol, outsider}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 2); + } + + // The list changed after signatures were collected: only the weight + // still on the live list counts. + env(signers(target, 2, {{bob, 1}, {dave, 1}})); + env.close(); + { + STObject payload = base; + multiSign(payload, {bob, carol}); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers[0].signedWeight == 1); + } + } + + void + testDelegateAuthorization(FeatureBitset features) + { + testcase("delegated proposed transaction"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const delegate{"delegate"}; + Account const dest{"dest"}; + env.fund(XRP(10000), alice, target, delegate, dest); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value payload = unsignedPayload(env, target, dest, ticketSeq); + payload[sfDelegate.getJsonName()] = delegate.human(); + auto const base = parsedPayload(env, alice, payload); + + // The delegate, not the target, is the required signer. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(status.signers[0].account == delegate.id()); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + + // The delegate's own signature satisfies it; the target's does not. + { + STObject signedPayload = base; + singleSign(signedPayload, delegate.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, signedPayload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + { + STObject signedPayload = base; + singleSign(signedPayload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, signedPayload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + } + } + + void + testCounterpartyAuthorization(FeatureBitset features) + { + testcase("counterparty co-signature"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const borrower{"borrower"}; + Account const lender{"lender"}; + env.fund(XRP(10000), alice, borrower, lender); + env.close(); + + auto const makeLoanSet = [&](jtx::Account const& counterparty) { + std::uint32_t const ticketSeq = env.seq(borrower) + 1; + env(ticket::create(borrower, 1)); + env.close(); + + json::Value tx = loan::set(borrower, uint256{1}, 1'000); + tx[sfCounterparty.getJsonName()] = counterparty.human(); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + return parsedPayload(env, alice, tx); + }; + + // Explicit Counterparty: a distinct required signer row. + { + auto const base = makeLoanSet(lender); + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 2); + auto const* row = findSigner(status, lender.id()); + BEAST_EXPECT(row && row->role == proposal::SignerRole::counterparty); + BEAST_EXPECT(!row->satisfied); + } + + // The lender co-signs through CounterpartySignature. + STObject counterSigned = base; + { + STObject sig(sfCounterpartySignature); + sig.setFieldVL(sfSigningPubKey, lender.pk().slice()); + sig.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + counterSigned.setFieldObject(sfCounterpartySignature, sig); + } + { + auto const status = + evaluate(env, makeProposalSLE(alice, counterSigned, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(findSigner(status, lender.id())->satisfied); + BEAST_EXPECT(!findSigner(status, borrower.id())->satisfied); + } + + // Both authorizations present: complete. + { + STObject payload = counterSigned; + singleSign(payload, borrower.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + // The implicit-counterparty rule is LoanSet's alone. Other types that + // carry sfLoanBrokerID (here LoanBrokerCoverDeposit) require no + // counterparty: exactly one signer row, and the initiator's own + // signature completes it. + { + std::uint32_t const ticketSeq = env.seq(borrower) + 1; + env(ticket::create(borrower, 1)); + env.close(); + + json::Value tx = loanBroker::coverDeposit(borrower, uint256{1}, XRP(100).value()); + tx[jss::Sequence] = 0; + tx[sfTicketSequence.getJsonName()] = ticketSeq; + tx[jss::Fee] = std::to_string(env.current()->fees().base.drops()); + tx[jss::SigningPubKey] = ""; + + STObject payload = parsedPayload(env, alice, tx); + singleSign(payload, borrower.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.signers.size() == 1); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + testSponsorAuthorization(FeatureBitset features) + { + testcase("sponsor co-signature"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; + Account const bob{"bob"}; + Account const patron{"patron"}; // the fee sponsor + env.fund(XRP(10000), alice, target, bob, patron); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + json::Value tx = unsignedPayload(env, target, bob, ticketSeq); + tx[sfSponsor.getJsonName()] = patron.human(); + tx[sfSponsorFlags.getJsonName()] = spfSponsorFee; + auto const base = parsedPayload(env, alice, tx); + + // The sponsor is a required signer; with no SponsorSignature and no + // pre-authorizing Sponsorship entry it is unsatisfied even when the + // target has signed. + { + STObject payload = base; + singleSign(payload, target.pk()); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + auto const* row = findSigner(status, patron.id()); + BEAST_EXPECT(row && row->role == proposal::SignerRole::sponsor && !row->satisfied); + } + + // A bare SponsorSignature placeholder must not fall back to the + // Sponsorship-entry exemption: once the field exists, submission + // validates it unconditionally. + { + STObject payload = base; + singleSign(payload, target.pk()); + payload.setFieldObject(sfSponsorSignature, STObject(sfSponsorSignature)); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(!findSigner(status, patron.id())->satisfied); + } + + // The sponsor's own signature completes it. + { + STObject payload = base; + singleSign(payload, target.pk()); + STObject sig(sfSponsorSignature); + sig.setFieldVL(sfSigningPubKey, patron.pk().slice()); + sig.setFieldVL(sfTxnSignature, Blob{0xDE, 0xAD, 0xBE, 0xEF}); + payload.setFieldObject(sfSponsorSignature, sig); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(findSigner(status, patron.id())->satisfied); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + testBatchAuthorization(FeatureBitset features) + { + testcase("proposed multi-account batch"); + + using namespace jtx; + + Env env{*this, features}; + + Account const alice{"alice"}; + Account const target{"target"}; // outer account of the batch + Account const bob{"bob"}; // a distinct inner participant + Account const carol{"carol"}; + Account const dave{"dave"}; + env.fund(XRP(10000), alice, target, bob, carol, dave); + env.close(); + + std::uint32_t const ticketSeq = env.seq(target) + 1; + env(ticket::create(target, 1)); + env.close(); + + auto const inner = [&](Account const& from, Account const& to, std::uint32_t seq) { + json::Value tx = pay(from, to, XRP(1)); + tx[jss::Sequence] = seq; + tx[jss::Fee] = "0"; + tx[jss::Flags] = tfInnerBatchTxn; + tx[jss::SigningPubKey] = ""; + return tx; + }; + + json::Value proposedTx; + proposedTx[jss::TransactionType] = jss::Batch; + proposedTx[jss::Account] = target.human(); + proposedTx[jss::Flags] = tfAllOrNothing; + proposedTx[jss::Sequence] = 0; + proposedTx[sfTicketSequence.getJsonName()] = ticketSeq; + proposedTx[jss::Fee] = std::to_string(batch::calcBatchFee(env, 1, 2).drops()); + proposedTx[jss::SigningPubKey] = ""; + proposedTx[jss::RawTransactions][0u][jss::RawTransaction] = + inner(target, bob, env.seq(target)); + proposedTx[jss::RawTransactions][1u][jss::RawTransaction] = inner(bob, target, env.seq(bob)); + + auto const base = parsedPayload(env, alice, proposedTx); + + // Unsigned: the outer account and the distinct participant are both + // required; the inner from the outer account adds no extra row. + { + auto const status = evaluate(env, makeProposalSLE(alice, base, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(status.signers.size() == 2); + auto const* outer = findSigner(status, target.id()); + auto const* participant = findSigner(status, bob.id()); + BEAST_EXPECT(outer && outer->role == proposal::SignerRole::account && !outer->satisfied); + BEAST_EXPECT( + participant && participant->role == proposal::SignerRole::batchParticipant && + !participant->satisfied); + } + + // The outer account signs the batch itself; bob is still missing. + STObject outerSigned = base; + singleSign(outerSigned, target.pk()); + { + auto const status = evaluate(env, makeProposalSLE(alice, outerSigned, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + BEAST_EXPECT(findSigner(status, target.id())->satisfied); + BEAST_EXPECT(!findSigner(status, bob.id())->satisfied); + } + + // Bob's single-signed BatchSigners entry completes the proposal. + { + STObject payload = outerSigned; + STArray batchSigners(sfBatchSigners); + batchSigners.push_back(makeBatchSigner(bob)); + payload.setFieldArray(sfBatchSigners, batchSigners); + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + + // Bob authorizes through his own SignerList inside his BatchSigners + // entry: quorum computed per participant. + env(signers(bob, 2, {{carol, 1}, {dave, 1}})); + env.close(); + { + STObject payload = outerSigned; + + STObject bobSigner(sfBatchSigner); + bobSigner.setAccountID(sfAccount, bob.id()); + // Multi-signing canonical form: SigningPubKey present and empty. + bobSigner.setFieldVL(sfSigningPubKey, Blob{}); + { + STObject multi(sfBatchSigner); // temp holder for the array + multiSign(multi, {carol}); + bobSigner.setFieldArray(sfSigners, multi.getFieldArray(sfSigners)); + } + STArray batchSigners(sfBatchSigners); + batchSigners.push_back(bobSigner); + payload.setFieldArray(sfBatchSigners, batchSigners); + + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(status.state == proposal::ProposalState::pending); + auto const* participant = findSigner(status, bob.id()); + BEAST_EXPECT(participant->signedWeight == 1 && participant->quorum == 2); + } + + // An inner from an account that does not exist yet (an earlier inner + // could create it) may be authorized by its own master key. + { + Account const phantom{"phantom"}; // never funded + + json::Value withPhantom = proposedTx; + withPhantom[jss::RawTransactions][2u][jss::RawTransaction] = + inner(phantom, target, 1); + auto const phantomBase = parsedPayload(env, alice, withPhantom); + + STObject payload = phantomBase; + singleSign(payload, target.pk()); + STArray batchSigners(sfBatchSigners); + std::vector entries{bob, phantom}; + std::sort(entries.begin(), entries.end(), [](auto const& a, auto const& b) { + return a.id() < b.id(); + }); + for (auto const& acct : entries) + batchSigners.push_back(makeBatchSigner(acct)); + payload.setFieldArray(sfBatchSigners, batchSigners); + + auto const status = evaluate(env, makeProposalSLE(alice, payload, farFuture(env))); + BEAST_EXPECT(findSigner(status, phantom.id())->satisfied); + BEAST_EXPECT(status.state == proposal::ProposalState::complete); + } + } + + void + run() override + { + using namespace jtx; + auto const features = testableAmendments(); + testMalformedRequests(features); + testPendingUnsigned(features); + testExpiredStates(features); + testSingleSignAuthorization(features); + testMultiSignAuthorization(features); + testDelegateAuthorization(features); + testCounterpartyAuthorization(features); + testSponsorAuthorization(features); + testBatchAuthorization(features); + } +}; + +BEAST_DEFINE_TESTSUITE(TransactionProposalRPC, rpc, xrpl); + +} // namespace xrpl::test diff --git a/src/xrpld/rpc/detail/Handler.cpp b/src/xrpld/rpc/detail/Handler.cpp index 4f5ce34c1fc..4036453dd2a 100644 --- a/src/xrpld/rpc/detail/Handler.cpp +++ b/src/xrpld/rpc/detail/Handler.cpp @@ -316,6 +316,10 @@ Handler const kHandlerArray[]{ .valueMethod = byRef(&doTransactionEntry), .role = Role::USER, .condition = Condition::NoCondition}, + {.name = "transaction_proposal", + .valueMethod = byRef(&doTransactionProposal), + .role = Role::USER, + .condition = Condition::NoCondition}, {.name = "tx", .valueMethod = byRef(&doTxJson), .role = Role::USER, diff --git a/src/xrpld/rpc/handlers/Handlers.h b/src/xrpld/rpc/handlers/Handlers.h index 7b347b2eccd..19006e218a2 100644 --- a/src/xrpld/rpc/handlers/Handlers.h +++ b/src/xrpld/rpc/handlers/Handlers.h @@ -129,6 +129,8 @@ doSubscribe(RPC::JsonContext&); json::Value doTransactionEntry(RPC::JsonContext&); json::Value +doTransactionProposal(RPC::JsonContext&); +json::Value doTxJson(RPC::JsonContext&); json::Value doTxHistory(RPC::JsonContext&); diff --git a/src/xrpld/rpc/handlers/TransactionProposal.cpp b/src/xrpld/rpc/handlers/TransactionProposal.cpp new file mode 100644 index 00000000000..1496aa973be --- /dev/null +++ b/src/xrpld/rpc/handlers/TransactionProposal.cpp @@ -0,0 +1,146 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +// "signed" is a C++ keyword, so it cannot be declared through the JSS macro. +static json::StaticString const kJssSigned{"signed"}; + +// A proposal is addressed either by its ledger-entry index (proposal_id) or +// by what that index is derived from: the proposed transaction's target +// account and TicketSequence. Field types, acceptance and error codes match +// ledger_entry's transaction_proposal addressing (parseTransactionProposal). +static std::expected +parseProposalID(json::Value const& params) +{ + bool const hasProposalID = params.isMember(jss::proposal_id); + bool const hasAccount = params.isMember(jss::account); + bool const hasTicketSeq = params.isMember(jss::ticket_seq); + + if (hasProposalID && !hasAccount && !hasTicketSeq) + { + return LedgerEntryHelpers::requiredUInt256(params, jss::proposal_id, "malformedRequest"); + } + + if (!hasProposalID && hasAccount && hasTicketSeq) + { + auto const target = + LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress"); + if (!target) + return std::unexpected(target.error()); + + auto const ticketSeq = + LedgerEntryHelpers::requiredUInt32(params, jss::ticket_seq, "malformedRequest"); + if (!ticketSeq) + return std::unexpected(ticketSeq.error()); + + return keylet::txProposal(*target, *ticketSeq).key; + } + + return std::unexpected( + RPC::makeParamError("Specify either proposal_id or account with ticket_seq.")); +} + +static char const* +signerRoleLabel(proposal::SignerRole role) +{ + switch (role) + { + case proposal::SignerRole::account: + return "account"; + case proposal::SignerRole::batchParticipant: + return "batch_participant"; + case proposal::SignerRole::counterparty: + return "counterparty"; + case proposal::SignerRole::sponsor: + return "sponsor"; + } + return "unknown"; // LCOV_EXCL_LINE +} + +static char const* +proposalStateLabel(proposal::ProposalState state) +{ + switch (state) + { + case proposal::ProposalState::pending: + return "pending"; + case proposal::ProposalState::complete: + return "complete"; + case proposal::ProposalState::expired: + return "expired"; + } + return "unknown"; // LCOV_EXCL_LINE +} + +json::Value +doTransactionProposal(RPC::JsonContext& context) +{ + std::shared_ptr lpLedger; + auto jvResult = RPC::lookupLedger(lpLedger, context); + + if (!lpLedger) + return jvResult; + + uint256 uNodeIndex; + try + { + auto const parsed = parseProposalID(context.params); + if (!parsed) + return parsed.error(); + uNodeIndex = *parsed; + } + catch (json::Error const&) + { + // A wrongly-typed parameter (e.g. an array where a scalar belongs) + // is the caller's error, not an internal one. + return RPC::makeError(RpcInvalidParams); + } + + // The typed keylet makes an index that names a different ledger entry + // type read as absent rather than as a proposal. + auto const sleProposal = lpLedger->read(keylet::txProposal(uNodeIndex)); + if (!sleProposal) + { + RPC::injectError(RpcEntryNotFound, jvResult); + return jvResult; + } + + auto const status = proposal::evaluateProposal(*lpLedger, *sleProposal, context.j); + + jvResult[jss::proposal_id] = to_string(uNodeIndex); + jvResult[jss::proposal] = sleProposal->getJson(JsonOptions::Values::None); + jvResult[jss::proposal_status] = proposalStateLabel(status.state); + + json::Value& signers = (jvResult[jss::signing_status] = json::ValueType::Array); + for (auto const& signer : status.signers) + { + json::Value entry{json::ValueType::Object}; + entry[jss::account] = toBase58(signer.account); + entry[jss::role] = signerRoleLabel(signer.role); + entry[kJssSigned] = signer.satisfied; + if (signer.signedWeight) + entry[jss::signed_weight] = *signer.signedWeight; + if (signer.quorum) + entry[jss::quorum] = *signer.quorum; + signers.append(entry); + } + + return jvResult; +} + +} // namespace xrpl