From cf74b7039f87757f706f8444ba1d4280c28b7c2a Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:32:27 -0400 Subject: [PATCH] Add ProposalCreate test harness (#8065) --- .../app/TransactionProposalCreate_test.cpp | 145 +++++++----- src/test/jtx/impl/proposal.cpp | 217 +++++++++++++++++- src/test/jtx/proposal.h | 41 ++++ 3 files changed, 340 insertions(+), 63 deletions(-) diff --git a/src/test/app/TransactionProposalCreate_test.cpp b/src/test/app/TransactionProposalCreate_test.cpp index 0f8fa60fd6..30ba7f4c1c 100644 --- a/src/test/app/TransactionProposalCreate_test.cpp +++ b/src/test/app/TransactionProposalCreate_test.cpp @@ -77,10 +77,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite target, proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq), proposal::expiration(env, 100s)), - Ter(temDISABLED)); + Ter(temDISABLED), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); // Its own Ticket is the only thing target owns; the rejected // proposal adds nothing on top of it. BEAST_EXPECT(ownerCount(env, target) == 1); @@ -117,9 +117,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // target's own Ticket is the only thing it owns throughout; a // rejected proposal never adds anything on top of it. auto reject = [&](json::Value const& proposedTx, TER expected) { - env(proposal::create(target, proposedTx, expiration), Ter(expected)); + env(proposal::create(target, proposedTx, expiration), + Ter(expected), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 1); }; @@ -228,9 +229,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // Expiration must be present and non-zero. { - env(proposal::create(target, payload(), 0), Ter(temBAD_EXPIRATION)); + env(proposal::create(target, payload(), 0), + Ter(temBAD_EXPIRATION), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 1); } } @@ -309,10 +311,13 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target) + 1)}); }; + // target's own Ticket is the only thing it owns throughout; a + // rejected proposal never adds anything on top of it. auto reject = [&](json::Value const& proposedTx, TER expected) { - env(proposal::create(target, proposedTx, expiration), Ter(expected)); + env(proposal::create(target, proposedTx, expiration), + Ter(expected), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 1); }; @@ -490,9 +495,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // The proposal's own expiration has already passed. { env(proposal::create(target, payload(firstTicketSeq), proposal::expiration(env, 0s)), - Ter(tecEXPIRED)); + Ter(tecEXPIRED), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, firstTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 3); } @@ -501,9 +506,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { json::Value tx = payload(firstTicketSeq); tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1; - env(proposal::create(target, tx, expiration), Ter(tecEXPIRED)); + env(proposal::create(target, tx, expiration), + Ter(tecEXPIRED), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, firstTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 3); } @@ -514,33 +520,36 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { json::Value tx = payload(firstTicketSeq); tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); - env(proposal::create(target, tx, expiration), Ter(tecEXPIRED)); + env(proposal::create(target, tx, expiration), + Ter(tecEXPIRED), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, firstTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 3); } // With no ledger bound on the proposed transaction, the proposal is // created normally. { - env(proposal::create(target, payload(firstTicketSeq), expiration)); + env(proposal::create(target, payload(firstTicketSeq), expiration), + proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, target, firstTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount); } // The target and ticket already carry a proposal. { - env(proposal::create(target, payload(firstTicketSeq), expiration), Ter(tecDUPLICATE)); + env(proposal::create(target, payload(firstTicketSeq), expiration), + Ter(tecDUPLICATE), + proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount); } // A different ticket of the same target is a different proposal. { - env(proposal::create(target, payload(firstTicketSeq + 1), expiration)); + env(proposal::create(target, payload(firstTicketSeq + 1), expiration), + proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, target, firstTicketSeq + 1)); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } @@ -550,9 +559,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { env(proposal::create( target, proposal::unsignedPayload(env, pay(carol, bob, XRP(1)), 1), expiration), - Ter(tecNO_TARGET)); + Ter(tecNO_TARGET), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, carol, 1)); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } @@ -560,9 +569,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // ticket that was never created (On-Chain Cosigner spec §5.3.2). { std::uint32_t const noSuchTicketSeq = firstTicketSeq + 100; - env(proposal::create(target, payload(noSuchTicketSeq), expiration), Ter(tefNO_TICKET)); + env(proposal::create(target, payload(noSuchTicketSeq), expiration), + Ter(tefNO_TICKET), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, noSuchTicketSeq)); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } } @@ -598,7 +608,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // The target account itself needs no SignerList entry. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); - env(proposal::create(target, payload(ticketSeq), proposal::expiration(env, 100s))); + env(proposal::create(target, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } @@ -606,7 +617,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite // An account on the target's SignerList may propose for it. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); - env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s))); + env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } @@ -615,7 +627,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); BEAST_EXPECT(ownerCount(env, stranger) == 0); @@ -633,7 +646,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite stranger, proposal::unsignedPayload(env, pay(bare, bob, XRP(1)), ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, bare, ticketSeq)); } @@ -656,7 +670,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite for (Account const& s : {s1, s2, s3, s4, s5}) { std::uint32_t const ticketSeq = proposal::createTicket(env, target); - env(proposal::create(s, payload(ticketSeq), proposal::expiration(env, 100s))); + env(proposal::create(s, payload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } @@ -665,7 +680,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } @@ -674,7 +690,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } @@ -719,7 +736,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } @@ -732,7 +750,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( - delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s))); + delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } @@ -743,8 +762,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env.close(); std::uint32_t const ticketSeq = proposal::createTicket(env, target); - env(proposal::create( - ds1, delegatedPayload(ticketSeq), proposal::expiration(env, 100s))); + env(proposal::create(ds1, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } @@ -755,7 +774,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( stranger, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } @@ -796,10 +816,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite json::Value const proposedTx = proposal::unsignedPayload(env, tx, 1); env(proposal::create(proposer, proposedTx, proposal::expiration(env, 100s)), - Ter(tecNO_PERMISSION)); + Ter(tecNO_PERMISSION), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, amm.ammAccount(), 1)); - BEAST_EXPECT(ownerCount(env, proposer) == 0); } void @@ -827,7 +846,7 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite std::uint32_t const expiration = proposal::expiration(env, 100s); - env(proposal::create(target, proposedTx, expiration)); + env(proposal::create(target, proposedTx, expiration), proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); @@ -843,10 +862,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq); BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty()); - // The proposal reserves several owner increments against the proposer. - // Here target is both: it owns the Ticket used by the proposed - // transaction, and it owns the proposal itself since it is proposing - // for its own account. + // The proposal reserves several owner increments against the proposer, + // which proposal::verify::create() checks. Here target is both: it owns + // the Ticket used by the proposed transaction, and it owns the proposal + // itself since it is proposing for its own account. BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kProposalOwnerCount); } @@ -893,9 +912,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite { std::uint32_t const ticketSeq = firstTicketSeq + static_cast(i); env(proposal::create( - target, proposal::unsignedPayload(env, payloads[i], ticketSeq), expiration)); + target, proposal::unsignedPayload(env, payloads[i], ticketSeq), expiration), + proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // target owns one Ticket per payload plus one proposal per payload. @@ -935,9 +954,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite json::Value const tx = proposal::unsignedPayload(env, loan::set(borrower, uint256{1}, 1'000), ticketSeq); - env(proposal::create(borrower, tx, expiration)); + env(proposal::create(borrower, tx, expiration), proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, borrower, ticketSeq)); } // SponsorshipTransfer (account-level reserve sponsorship): the @@ -950,9 +968,9 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve; env(proposal::create( - borrower, proposal::unsignedPayload(env, tx, ticketSeq), expiration)); + borrower, proposal::unsignedPayload(env, tx, ticketSeq), expiration), + proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, borrower, ticketSeq)); } } @@ -986,18 +1004,16 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); - env(proposal::create(alice, proposedTx, expiration), Ter(tecINSUFFICIENT_RESERVE)); + env(proposal::create(alice, proposedTx, expiration), + Ter(tecINSUFFICIENT_RESERVE), + proposal::verify::create()); env.close(); - BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); - BEAST_EXPECT(ownerCount(env, alice) == 0); env(pay(bob, alice, XRP(10))); env.close(); - env(proposal::create(alice, proposedTx, expiration)); + env(proposal::create(alice, proposedTx, expiration), proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq)); - BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); } // The proposal's reserve can instead be sponsored: the reserve is charged @@ -1034,7 +1050,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer, spfSponsorReserve), Sig(sfSponsorSignature, backer), - Ter(temDISABLED)); + Ter(temDISABLED), + proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); } @@ -1056,7 +1073,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer, spfSponsorReserve), - Sig(sfSponsorSignature, backer)); + Sig(sfSponsorSignature, backer), + proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); @@ -1105,7 +1123,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer1, spfSponsorReserve), - Sig(sfSponsorSignature, backer1)); + Sig(sfSponsorSignature, backer1), + proposal::verify::create()); env.close(); BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == proposal::kProposalOwnerCount); @@ -1169,7 +1188,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), Fee(feeAmt), sponsor::As(backer, spfSponsorFee), - Sig(sfSponsorSignature, backer)); + Sig(sfSponsorSignature, backer), + proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq)); @@ -1206,10 +1226,10 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)), proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)}); - env(proposal::create(target, proposedTx, proposal::expiration(env, 100s))); + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); - BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq)); // target owns its own Ticket plus the batch proposal. BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount); } @@ -1246,7 +1266,8 @@ struct TransactionProposalCreate_test : public beast::unit_test::Suite {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)), proposal::innerTx(pay(bob, target, XRP(1)), env.seq(bob))}); - env(proposal::create(target, proposedTx, proposal::expiration(env, 100s))); + env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), + proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); diff --git a/src/test/jtx/impl/proposal.cpp b/src/test/jtx/impl/proposal.cpp index c820ef6392..3497f2d744 100644 --- a/src/test/jtx/impl/proposal.cpp +++ b/src/test/jtx/impl/proposal.cpp @@ -2,23 +2,33 @@ #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 @@ -29,7 +39,7 @@ json::Value create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration) { json::Value jv; - jv[jss::TransactionType] = "TransactionProposalCreate"; + jv[jss::TransactionType] = jss::TransactionProposalCreate; jv[jss::Account] = proposer.human(); jv[sfProposedTransaction.jsonName] = proposedTx; jv[sfExpiration.jsonName] = expiration; @@ -132,4 +142,209 @@ entry(Env const& env, Account const& target, std::uint32_t ticketSeq) return entry(env, target.id(), ticketSeq); } +namespace { + +// The keys an account's owner directory lists, each with the page it sits on. +// The pages are read directly, so a key with nothing behind it is still seen. +std::map +ownerDirKeys(ReadView const& view, AccountID const& account) +{ + std::map keys; + + auto const root = keylet::ownerDir(account); + std::uint64_t page = 0; + for (auto sle = view.read(root); sle;) + { + for (auto const& key : sle->getFieldV256(sfIndexes)) + keys.emplace(key, page); + + page = sle->getFieldU64(sfIndexNext); + if (page == 0) + break; + sle = view.read(keylet::page(root, page)); + } + + return keys; +} + +// Whether two reads of a ledger entry found it unchanged, or found nothing both +// times. Entries that are there are compared whole. +bool +unchanged(SLE::const_pointer const& before, SLE::const_pointer const& after) +{ + if (!before || !after) + return !before && !after; + + return before->key() == after->key() && + static_cast(*before) == static_cast(*after); +} + +// Whether a TransactionProposal entry may carry the field when it is created. +// A fresh proposal has gathered no signatures, so it carries nothing else — +// except a Sponsor, which a reserve-sponsored creation stamps on the entry from +// the start. +bool +isCreationField(SField const& field) +{ + return field == sfLedgerEntryType || field == sfFlags || field == sfOwner || + field == sfProposedTransaction || field == sfExpiration || field == sfOwnerNode || + field == sfPreviousTxnID || field == sfPreviousTxnLgrSeq || field == sfSponsor; +} + +} // namespace + +void +verify::Create::operator()(Env& env, JTx& jt) const +{ + // Only a TransactionProposalCreate carries the fields read below, and a + // condition that quietly checks nothing is worse than none at all. + if (jt.jv[jss::TransactionType].asString() != jss::TransactionProposalCreate.cStr()) + Throw("proposal::verify::create: not a TransactionProposalCreate"); + + // Funclets run before the transaction is applied, so everything read here + // is the state the effects are measured against. + auto const& proposedTx = jt.jv[sfProposedTransaction.jsonName]; + auto const parsedTarget = parseBase58(proposedTx[jss::Account].asString()); + + // A payload naming no usable target is a malformed case a test is making + // on purpose, and has no ledger effect to measure. + if (!parsedTarget) + return; + + auto const target = *parsedTarget; + auto const proposer = env.lookup(jt.jv[jss::Account].asString()); + auto const ticketSeq = proposedTx[sfTicketSequence.jsonName].asUInt(); + auto const expiration = jt.jv[sfExpiration.jsonName].asUInt(); + auto const cost = proposedTx[jss::TransactionType].asString() == jss::Batch.cStr() + ? kBatchProposalOwnerCount + : kProposalOwnerCount; + + std::optional reserveSponsor; + if (jt.jv.isMember(sfSponsor.jsonName) && + (jt.jv[sfSponsorFlags.jsonName].asUInt() & spfSponsorReserve) != 0) + reserveSponsor.emplace(env.lookup(jt.jv[sfSponsor.jsonName].asString())); + + // Every entry the transaction could touch, read whole, so what follows can + // say that nothing moved rather than that the fields we named did not. + auto const& view = *env.current(); + auto const proposalKeylet = keylet::txProposal(target, ticketSeq); + auto const ownerCountBefore = env.ownerCount(proposer); + auto const sponsoredOwnerCountBefore = env.sponsoredOwnerCount(proposer); + auto const sponsoringOwnerCountBefore = + reserveSponsor ? std::optional{env.sponsoringOwnerCount(*reserveSponsor)} : std::nullopt; + auto const proposalBefore = view.read(proposalKeylet); + auto const targetBefore = view.read(keylet::account(target)); + auto const ticketBefore = view.read(keylet::ticket(target, ticketSeq)); + auto const proposerDirBefore = ownerDirKeys(view, proposer.id()); + auto const targetDirBefore = ownerDirKeys(view, target); + + jt.require.emplace_back([=](Env& applied) { + auto& test = applied.test; + auto const& view = *applied.current(); + + auto const created = isTesSuccess(applied.ter()); + + // The proposer owns the proposal even when another account covers its + // reserve. A proposed Batch costs more owner-count increments. + test.expect( + applied.ownerCount(proposer) == ownerCountBefore + (created ? cost : 0), + "proposal reserve"); + test.expect( + applied.sponsoredOwnerCount(proposer) == + sponsoredOwnerCountBefore + (created && reserveSponsor ? cost : 0), + "proposal sponsored owner count"); + if (reserveSponsor) + { + test.expect( + applied.sponsoringOwnerCount(*reserveSponsor) == + *sponsoringOwnerCountBefore + (created ? cost : 0), + "proposal sponsoring owner count"); + } + + // The target's ticket is left for the proposed transaction, including + // when the target is also the proposer. + test.expect( + unchanged(ticketBefore, view.read(keylet::ticket(target, ticketSeq))), + "proposal target ticket"); + + // Nothing else of a distinct target's moves: the proposal belongs in + // the proposer's account and owner directory. + if (target != proposer.id()) + { + test.expect( + unchanged(targetBefore, view.read(keylet::account(target))), + "proposal target account"); + test.expect(ownerDirKeys(view, target) == targetDirBefore, "proposal target directory"); + } + + auto const sleProposal = view.read(proposalKeylet); + + if (!created) + { + // A create that did not succeed leaves the proposal as it found + // it, down to the last field. + test.expect(unchanged(proposalBefore, sleProposal), "proposal unchanged"); + + // Nor did the directory gain a listing for an entry that does not + // exist. + test.expect( + ownerDirKeys(view, proposer.id()) == proposerDirBefore, "proposal owner directory"); + return; + } + + // A successful create must have created the entry, not overwritten one + // that was already there. The checks below read the entry after the + // write, so they pass either way; this is what rules an overwrite out. + if (!test.expect(!proposalBefore, "proposal is new") || + !test.expect(sleProposal, "proposal entry")) + return; + + // What is on the ledger is what was submitted: a proposal is only worth + // collecting signatures against if the transaction it stores is the one + // proposed, so the payload is compared whole. + test.expect(sleProposal->getAccountID(sfOwner) == proposer.id(), "proposal owner"); + test.expect(sleProposal->getFieldU32(sfExpiration) == expiration, "proposal expiration"); + test.expect( + reserveSponsor ? sleProposal->isFieldPresent(sfSponsor) && + sleProposal->getAccountID(sfSponsor) == reserveSponsor->id() + : !sleProposal->isFieldPresent(sfSponsor), + "proposal sponsor"); + + auto const& stored = sleProposal->getFieldObject(sfProposedTransaction); + test.expect(stored == parse(proposedTx), "proposal payload"); + + // Unsigned canonical form, keyed by the target and ticket the payload + // names (On-Chain Cosigner spec §6.1). + test.expect( + xrpl::proposal::hasEmptySigningPubKey(stored) && + !xrpl::proposal::hasSignatureField(stored), + "proposal payload unsigned"); + test.expect( + stored.getAccountID(sfAccount) == target && + stored.getFieldU32(sfTicketSequence) == ticketSeq && + stored.getFieldU32(sfSequence) == 0, + "proposal payload target"); + + // Nothing beyond the fields a fresh proposal is created with. An + // STObject carries a placeholder for each optional field its format + // allows, so each field is asked whether it is really present. + test.expect(sleProposal->getFieldU32(sfFlags) == 0, "proposal flags"); + for (auto const& field : *sleProposal) + { + if (field.getSType() != STI_NOTPRESENT) + { + test.expect( + isCreationField(field.getFName()), + "proposal field " + field.getFName().getName()); + } + } + + // The proposal is listed in the proposer's directory on the page its + // OwnerNode names, and nothing else listed moved. + auto expectedDir = proposerDirBefore; + expectedDir.emplace(sleProposal->key(), sleProposal->getFieldU64(sfOwnerNode)); + test.expect(ownerDirKeys(view, proposer.id()) == expectedDir, "proposal owner directory"); + }); +} + } // namespace xrpl::test::jtx::proposal diff --git a/src/test/jtx/proposal.h b/src/test/jtx/proposal.h index ddb99665bb..2703fd6046 100644 --- a/src/test/jtx/proposal.h +++ b/src/test/jtx/proposal.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -39,6 +40,46 @@ using xrpl::proposal::kProposalOwnerCount; json::Value create(Account const& proposer, json::Value const& proposedTx, std::uint32_t expiration); +/** + * @brief Conditions that check what a proposal transaction did to the ledger. + * + * Each is named for the generator it verifies, so a submission and its check + * read as a pair, and the transactions that later sign or complete a proposal + * get their own entries here rather than sharing one. + */ +namespace verify { + +/** + * @brief The condition returned by create(); see it for what is checked. + */ +class Create +{ +public: + /** + * @throws std::logic_error if attached to another transaction type. + */ + void + operator()(Env&, JTx&) const; +}; + +/** + * @brief Check the ledger effects a TransactionProposalCreate must have, + * whatever its outcome: on tesSUCCESS a new proposal holding what was + * submitted, listed in the proposer's directory and paid for by its reserve; + * otherwise nothing moved. Nothing of the target's moves either way. + * + * @code + * env(proposal::create(alice, payload, expiration), proposal::verify::create()); + * @endcode + */ +[[nodiscard]] inline Create +create() +{ + return Create{}; +} + +} // namespace verify + /** * @brief Put a transaction of any type into the form a proposal stores it in: * unsigned and ticket-based, with the fee the target account will pay fixed