#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 #include #include #include #include // IWYU pragma: keep #include #include #include #include #include #include namespace xrpl::test { struct TransactionProposalCreate_test : public beast::unit_test::Suite { void testReserveCounts() { testcase("proposal reserve"); using namespace jtx; BEAST_EXPECT(proposal::kProposalOwnerCount == 5); BEAST_EXPECT(proposal::kBatchProposalOwnerCount == 10); } // Nothing about the transaction is available before the amendment is // active, not even to an otherwise valid proposal. void testDisabled(FeatureBitset features) { testcase("amendment disabled"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features - featureCosign}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); env(proposal::create( target, proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq), proposal::expiration(env, 100s)), Ter(temDISABLED), proposal::verify::create()); env.close(); // Its own Ticket is the only thing target owns; the rejected // proposal adds nothing on top of it. BEAST_EXPECT(ownerCount(env, target) == 1); } // The proposed transaction must be a transaction that could be submitted on // its own. Each case below takes an otherwise valid payload and breaks // exactly one of those rules; the rules about its signature fields are // covered by testRejectedSignatureFields. void testRejectedPayload(FeatureBitset features) { testcase("reject payload that must not be stored"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); std::uint32_t const expiration = proposal::expiration(env, 100s); // A payload that is accepted as-is; every case starts from this. auto payload = [&]() { return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); }; // 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), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 1); }; // An unrecognized TransactionType cannot even be constructed as an // STTx (there is no format to validate it against), so it is // rejected the same as any other malformed payload. { json::Value tx = payload(); tx[jss::TransactionType] = 65535; reject(tx, temMALFORMED); } // A pseudo-transaction is never submittable by an account. This // payload also carries Payment-shaped fields (Amount, Destination) // that aren't part of EnableAmendment's own template, so it fails // STTx construction before ever reaching our own isPseudoTx check. { json::Value tx = payload(); tx[jss::TransactionType] = jss::EnableAmendment; reject(tx, temMALFORMED); } // An inner batch transaction bypasses the ordinary signature checks. // The proposed transaction's own preflight rejects a standalone // tfInnerBatchTxn (no enclosing Batch, no parentBatchId) with its own // more specific code before reaching our own tfInnerBatchTxn check. { json::Value tx = payload(); tx[jss::Flags] = tfInnerBatchTxn; reject(tx, temINVALID_INNER_BATCH); } // Proposals do not nest. This payload also isn't a valid instance of // TransactionProposalCreate's own template (it lacks Expiration and // ProposedTransaction), so it fails STTx construction before ever // reaching our own isProposalTx check. { json::Value tx = payload(); tx[jss::TransactionType] = "TransactionProposalCreate"; reject(tx, temMALFORMED); } // Nor may a proposed Batch smuggle a nested proposal in as one of its // own inner transactions. A second, ordinary inner transaction rides // along only to satisfy Batch's own minimum of two inner // transactions; it isn't itself the point of this case. { json::Value const nestedProposal = proposal::create(target, payload(), proposal::expiration(env, 100s)); json::Value const tx = proposal::unsignedBatch( env, target, targetTicketSeq, tfAllOrNothing, {proposal::innerTx(nestedProposal, env.seq(target)), proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)}); reject(tx, temINVALID); } // The proposed transaction must be ticket-based: a missing // TicketSequence, or a live Sequence alongside it, is rejected. { json::Value tx = payload(); tx.removeMember(sfTicketSequence.getJsonName()); reject(tx, temSEQ_AND_TICKET); } { json::Value tx = payload(); tx[jss::Sequence] = 1; reject(tx, temSEQ_AND_TICKET); } // If this TransactionProposalCreate itself pays with a Ticket, and the // proposed transaction targets that same account and Ticket, applying // this transaction consumes the Ticket the proposal depends on before // the proposal is even stored: it would be dead on arrival. target is // proposing for itself here, so this is the Ticket it is about to pay // with and the Ticket its own proposed payload names. { std::uint32_t const selfTicketSeq = proposal::createTicket(env, target); json::Value const tx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), selfTicketSeq); env(proposal::create(target, tx, expiration), ticket::Use(selfTicketSeq), Ter(temMALFORMED)); env.close(); BEAST_EXPECT(!proposal::entry(env, target, selfTicketSeq)); BEAST_EXPECT(env.le(keylet::ticket(target.id(), SeqProxy::rawTicket(selfTicketSeq)))); BEAST_EXPECT(ownerCount(env, target) == 2); // Consume the leftover Ticket so target's OwnerCount is back to // just its original Ticket for the remaining cases below. env(noop(target), ticket::Use(selfTicketSeq)); env.close(); BEAST_EXPECT(ownerCount(env, target) == 1); } // A payload that fails its own transaction type's preflight surfaces // that type's own code, not a generic error (On-Chain Cosigner spec §5.3.1.2). { json::Value tx = payload(); tx[jss::Amount] = "0"; reject(tx, temBAD_AMOUNT); } // Expiration must be present and non-zero. { env(proposal::create(target, payload(), 0), Ter(temBAD_EXPIRATION), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 1); } } // A proposal is stored in unsigned canonical form: an empty SigningPubKey // and no signature field whatsoever. Signatures may only ever arrive // through TransactionProposalSign, so a payload is rejected for carrying a // signature container at all — whatever that container happens to hold. // Each container below is therefore filled every way it could be, // including combinations that could never verify: an empty container, a // key with no signature, a signature with no key, and a signature next to // the empty SigningPubKey the canonical form requires. // // The rejection code is not uniform, though. A fill that leaves actual // signature bytes behind (a non-empty TxnSignature, or one inside a // Signers entry) is, for some containers, intercepted before ever // reaching our own hasSignatureField check: the payload's own top-level // fields are checked by Transactor::preflight2's dry-run simulate-key // logic, and LoanSet forwards its CounterpartySignature through that same // logic, both yielding temINVALID instead. SponsorSignature and // BatchSigners are not inspected that way — only their presence is // checked elsewhere — so they always reach our own check regardless of // what they hold. void testRejectedSignatureFields(FeatureBitset features) { testcase("reject payload carrying a signature"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); std::uint32_t const expiration = proposal::expiration(env, 100s); std::string const key = strHex(bob.pk().slice()); std::string const sig = "DEADBEEF"; // The payloads every case starts from, each accepted as-is. Two cases // below would otherwise build the same payload, and the same proposal // cannot be submitted twice — the second is turned away as a duplicate // rather than judged again — so each call pays a different amount. // Nothing here turns on the amount. std::uint32_t paid = 0; auto payment = [&]() { return proposal::unsignedPayload(env, pay(target, bob, drops(++paid)), targetTicketSeq); }; auto sponsoredPayment = [&]() { // bob is just standing in for an arbitrary sponsor here; every case // is rejected for carrying a signature field before the sponsor // itself is ever examined. json::Value tx = pay(target, bob, drops(++paid)); tx[sfSponsor.getJsonName()] = bob.human(); tx[sfSponsorFlags.getJsonName()] = spfSponsorFee; return proposal::unsignedPayload(env, tx, targetTicketSeq); }; auto loanSet = [&]() { json::Value tx = loan::set(target, uint256{1}, 1'000 + ++paid); tx[sfCounterparty.getJsonName()] = bob.human(); return proposal::unsignedPayload(env, tx, targetTicketSeq); }; auto batchTx = [&]() { return proposal::unsignedBatch( env, target, targetTicketSeq, tfAllOrNothing, {proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target)), 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), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 1); }; // Every way of filling in a signature. The payload's own signature // fields and a co-signature object hold the same three members, so the // same fills apply to both. `signs` marks a fill that leaves actual // signature bytes behind, which some containers' own dry-run // simulate-key check reacts to (see the class comment above). struct Fill { std::function apply; bool signs; }; std::vector const fills{ {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = key; }, .signs = false}, {.apply = [&](json::Value& o) { o[sfTxnSignature.getJsonName()] = sig; }, .signs = true}, {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = ""; o[sfTxnSignature.getJsonName()] = sig; }, .signs = true}, // Signed the ordinary way, which is the likeliest way one of these // arrives here. {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = key; o[sfTxnSignature.getJsonName()] = sig; }, .signs = true}, // Multi-signed: the signer's own key is empty and the signatures // sit in a nested Signers array. Each entry needs all three of // Account, SigningPubKey and TxnSignature to parse at all, so only // their values can vary. {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = ""; auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; signer[jss::Account] = bob.human(); signer[jss::SigningPubKey] = key; signer[sfTxnSignature.getJsonName()] = sig; }, .signs = true}, {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = ""; auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()]; signer[jss::Account] = bob.human(); signer[jss::SigningPubKey] = ""; signer[sfTxnSignature.getJsonName()] = sig; }, .signs = true}, }; // Every place a signature could sit, on a payload of a type that // carries it: a Counterparty's signature belongs to a LoanSet and // BatchSigners to a Batch, while a Sponsor's signature and the // payload's own signature fields sit on any transaction. A signature // is no more storable for being a field its transaction type expects // (On-Chain Cosigner spec §6.1, §6.6.3). `checksSignatureContent` // marks a place whose own preflight forwards the container through a // dry-run simulate-key check, the same as the payload's own top-level // fields. struct Place { std::function payload; std::function at; bool checksSignatureContent; }; std::vector const places{ {.payload = payment, .at = [](json::Value& tx) -> json::Value& { return tx; }, .checksSignatureContent = true}, {.payload = loanSet, .at = [](json::Value& tx) -> json::Value& { auto& o = tx[sfCounterpartySignature.getJsonName()]; o = json::Value{json::ValueType::Object}; return o; }, .checksSignatureContent = true}, {.payload = sponsoredPayment, .at = [](json::Value& tx) -> json::Value& { auto& o = tx[sfSponsorSignature.getJsonName()]; o = json::Value{json::ValueType::Object}; return o; }, .checksSignatureContent = false}, // A BatchSigners entry names the account it speaks for; the other // two co-signatures are fixed by the transaction they belong to and // do not. {.payload = batchTx, .at = [&](json::Value& tx) -> json::Value& { auto& o = tx[sfBatchSigners.getJsonName()][0u][sfBatchSigner.getJsonName()]; o[jss::Account] = bob.human(); return o; }, .checksSignatureContent = false}, }; for (auto const& place : places) { for (auto const& fill : fills) { json::Value tx = place.payload(); fill.apply(place.at(tx)); reject(tx, place.checksSignatureContent && fill.signs ? temINVALID : temBAD_SIGNER); } } // Every place but the payload itself: a co-signature object is // disqualifying by its presence alone, so each is rejected left empty // too. The payload's own fields have no such case — left alone they are // the canonical form. An empty container never trips a simulate-key // check, so this is temBAD_SIGNER regardless of checksSignatureContent. for (std::size_t i = 1; i < places.size(); ++i) { json::Value tx = places[i].payload(); places[i].at(tx); reject(tx, temBAD_SIGNER); } // Nor does the payload have a counterpart for an absent SigningPubKey: // in a co-signature object an absent member is just an unfilled one, // but at the top level it is not the same as an empty one, with or // without a signature beside it. SigningPubKey is a required common // field, so its absence means proposedTx isn't a valid instance of its // own type; that's caught while constructing it as an STTx, before // reaching our own hasEmptySigningPubKey check. { json::Value tx = payment(); tx.removeMember(jss::SigningPubKey); reject(tx, temMALFORMED); } { json::Value tx = payment(); tx.removeMember(jss::SigningPubKey); tx[sfTxnSignature.getJsonName()] = sig; reject(tx, temMALFORMED); } } // A proposal that could never be completed must not be stored, and a // target-and-ticket pair may hold at most one proposal. void testPreclaim(FeatureBitset features) { testcase("reject proposal that cannot be completed"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; Account const carol{"carol"}; // never funded env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const firstTicketSeq = proposal::createTicket(env, target, 3); std::uint32_t const expiration = proposal::expiration(env, 100s); auto payload = [&](std::uint32_t ticketSeq) { return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq); }; // target's three Tickets are owned throughout, so its OwnerCount // never drops below 3; each successful proposal adds kProposalOwnerCount // on top of that baseline. // The proposal's own expiration has already passed. { env(proposal::create(target, payload(firstTicketSeq), proposal::expiration(env, 0s)), Ter(tecEXPIRED), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3); } // The proposed transaction's own ledger bound has passed: the ordinary // path would reject it with tefMAX_LEDGER, so it can never complete. { json::Value tx = payload(firstTicketSeq); tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1; env(proposal::create(target, tx, expiration), Ter(tecEXPIRED), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3); } // A LastLedgerSequence equal to the current ledger leaves no window to // collect signatures before the proposed transaction's own bound // passes, so it is rejected the same as one already in the past // (On-Chain Cosigner spec §5.3.2.2). { json::Value tx = payload(firstTicketSeq); tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq(); env(proposal::create(target, tx, expiration), Ter(tecEXPIRED), proposal::verify::create()); env.close(); 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), proposal::verify::create()); env.close(); 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), 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), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } // The target account does not exist, so it can never sign. target // itself is just standing in here as an arbitrary funded submitter — // the account under test is carol, the (nonexistent) target. { env(proposal::create( target, proposal::unsignedPayload(env, pay(carol, bob, XRP(1)), 1), expiration), Ter(tecNO_TARGET), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } // The referenced ticket does not exist: the proposal would reserve a // 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), proposal::verify::create()); env.close(); BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount)); } } // Only the target account itself, or an account on its SignerList, may // create a proposal against it. Otherwise any unrelated account could // spam or squat the target's Tickets with unwanted proposals (On-Chain // Cosigner V1 authorization scope). void testProposerAuthorization(FeatureBitset features) { testcase("reject proposal from an unauthorized proposer"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const signer{"signer"}; Account const stranger{"stranger"}; Account const bob{"bob"}; env.fund(XRP(10000), target, signer, stranger, bob); env.close(); env(signers(target, 1, {{signer, 1}})); env.close(); auto payload = [&](std::uint32_t ticketSeq) { return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq); }; // 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)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // 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)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // An account that is neither the target nor on its SignerList may not. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); BEAST_EXPECT(ownerCount(env, stranger) == 0); } // A target with no SignerList at all may only be proposed for by // itself. { Account const bare{"bare"}; env.fund(XRP(10000), bare); env.close(); std::uint32_t const ticketSeq = proposal::createTicket(env, bare); env(proposal::create( stranger, proposal::unsignedPayload(env, pay(bare, bob, XRP(1)), ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, bare, ticketSeq)); } // A SignerList with several entries authorizes every one of them, not // just the first, matching a real-world multi-signer setup rather // than only ever exercising a single-signer list. { Account const s1{"s1"}; Account const s2{"s2"}; Account const s3{"s3"}; Account const s4{"s4"}; Account const s5{"s5"}; env.fund(XRP(10000), s1, s2, s3, s4, s5); env.close(); env(signers(target, 3, {{s1, 1}, {s2, 1}, {s3, 1}, {s4, 1}, {s5, 1}})); env.close(); 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)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // The old SignerList's sole signer is no longer on the new one. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } // An unrelated account still may not. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } } } // An on-ledger ltSIGNER_LIST that cannot be read as signer entries is // unexpected ledger state, not a malformed transaction. preclaim must // surface tefBAD_LEDGER (not temMALFORMED, not tefINTERNAL which is // reserved for truly unreachable paths, and not the tefEXCEPTION that // applySteps would wrap an uncaught throw with). // // SignerEntries::deserialize returns unexpected(temMALFORMED) when // sfSignerEntries is missing or an element is not named sfSignerEntry. // It still throws from STObject accessors when an sfSignerEntry is // missing required fields (getAccountID → "Field not found: Account"). // Do not close() after the synthetic corruption: a closed ledger would // drop the overlay and restore a well-formed list. void testCorruptSignerList(FeatureBitset features) { testcase("unparseable on-ledger SignerList is tefBAD_LEDGER"); using namespace jtx; using namespace std::chrono_literals; auto setup = [&](Env& env, Account const& target, Account const& signer) { env.fund(XRP(10000), target, signer); env.close(); env(signers(target, 1, {{signer, 1}})); env.close(); // Ticket first: createTicket closes, which would drop a later // open-ledger overlay and restore a well-formed SignerList. return proposal::createTicket(env, target); }; auto proposeAsSigner = [&](Env& env, Account const& target, Account const& signer, std::uint32_t ticketSeq) { env(proposal::create( signer, proposal::unsignedPayload(env, pay(target, signer, XRP(1)), ticketSeq), proposal::expiration(env, 100s)), Ter(tefBAD_LEDGER), proposal::verify::create()); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); }; { Env env{*this, features}; Account const target{"targetMissing"}; Account const signer{"signerMissing"}; std::uint32_t const ticketSeq = setup(env, target, signer); auto const signerListKeylet = keylet::signerList(target.id()); BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const sle = view.read(signerListKeylet); if (!sle) return false; auto replacement = std::make_shared(*sle); if (!replacement->delField(sfSignerEntries)) return false; view.rawReplace(replacement); return true; })); BEAST_EXPECT(env.le(signerListKeylet)); proposeAsSigner(env, target, signer, ticketSeq); } { Env env{*this, features}; Account const target{"targetBadEntry"}; Account const signer{"signerBadEntry"}; std::uint32_t const ticketSeq = setup(env, target, signer); auto const signerListKeylet = keylet::signerList(target.id()); BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const sle = view.read(signerListKeylet); if (!sle) return false; auto replacement = std::make_shared(*sle); STArray badEntries; badEntries.pushBack(STObject{sfSigner}); replacement->setFieldArray(sfSignerEntries, badEntries); view.rawReplace(replacement); return true; })); BEAST_EXPECT(env.le(signerListKeylet)); proposeAsSigner(env, target, signer, ticketSeq); } { Env env{*this, features}; Account const target{"targetMissingAccount"}; Account const signer{"signerMissingAccount"}; std::uint32_t const ticketSeq = setup(env, target, signer); auto const signerListKeylet = keylet::signerList(target.id()); BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) { auto const sle = view.read(signerListKeylet); if (!sle) return false; auto replacement = std::make_shared(*sle); STArray badEntries; // Right inner name, but no sfAccount: deserialize calls // getAccountID and throws (Field not found), which the // catch maps to tefBAD_LEDGER. badEntries.pushBack(STObject{sfSignerEntry}); replacement->setFieldArray(sfSignerEntries, badEntries); view.rawReplace(replacement); return true; })); BEAST_EXPECT(env.le(signerListKeylet)); proposeAsSigner(env, target, signer, ticketSeq); } } // The target account may delegate authority over the proposed // transaction's own type to another account (Permission Delegation, // XLS-75); if it does, that delegate — or an account on the delegate's // own SignerList — may also create the proposal, since it will need to // help complete the proposed transaction's own authorization anyway. // Naming an account as Delegate in the proposed transaction is not // itself trusted: a real DelegateSet grant is required. void testDelegatedProposedTx(FeatureBitset features) { testcase("proposer authorized through a delegated proposed txn"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const delegateAcct{"delegateAcct"}; Account const ds1{"ds1"}; // on delegateAcct's own SignerList Account const ds2{"ds2"}; // on delegateAcct's own SignerList Account const stranger{"stranger"}; Account const bob{"bob"}; env.fund(XRP(10000), target, delegateAcct, ds1, ds2, stranger, bob); env.close(); auto delegatedPayload = [&](std::uint32_t ticketSeq) { json::Value tx = pay(target, bob, XRP(1)); tx[sfDelegate.jsonName] = delegateAcct.human(); return proposal::unsignedPayload(env, tx, ticketSeq); }; // Without a real DelegateSet grant, naming an account as Delegate in // the proposed transaction does not authorize it. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } // The target grants delegateAcct permission over Payment transactions. env(delegate::set(target, delegateAcct, {"Payment"})); env.close(); // The delegate itself may now create the proposal. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // An account on the delegate's own SignerList may likewise create it. { env(signers(delegateAcct, 1, {{ds1, 1}, {ds2, 1}})); env.close(); std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create(ds1, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // An account with no relationship to the target or the delegate is // still rejected. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); env(proposal::create( stranger, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } } // A delegate holding only a granular permission (XLS-75) that would // authorize submitting the proposed transaction may also create a // proposal for it. A granular grant that fails checkGranularSandbox // still cannot. void testDelegatedGranularProposedTx(FeatureBitset features) { testcase("proposer authorized through granular delegate permission"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const gw{"gw"}; // issuer / proposed-tx Account Account const alice{"alice"}; // holder of the trust line being authorized Account const bob{"bob"}; // delegate with TrustlineAuthorize only env.fund(XRP(10000), gw, alice, bob); env(fset(gw, asfRequireAuth)); env.close(); env(trust(alice, gw["USD"](50))); env.close(); env(delegate::set(gw, bob, {"TrustlineAuthorize"})); env.close(); auto delegatedTrustSet = [&](std::uint32_t ticketSeq, std::uint32_t flags) { json::Value tx = trust(gw, gw["USD"](0), alice, flags); tx[sfDelegate.jsonName] = bob.human(); return proposal::unsignedPayload(env, tx, ticketSeq); }; // TrustlineAuthorize is sufficient for a tfSetfAuth TrustSet against // an existing line whose limit is unchanged — the same shape that // submits successfully under invokeCheckPermission. { std::uint32_t const ticketSeq = proposal::createTicket(env, gw); env(proposal::create( bob, delegatedTrustSet(ticketSeq, tfSetfAuth), proposal::expiration(env, 100s)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, gw, ticketSeq)); } // tfSetFreeze is not in TrustlineAuthorize's sandbox. { std::uint32_t const ticketSeq = proposal::createTicket(env, gw); env(proposal::create( bob, delegatedTrustSet(ticketSeq, tfSetFreeze), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq)); } // sfQualityOut is a valid TrustSet field but not in the granular // template, so checkGranularSandbox rejects it. { std::uint32_t const ticketSeq = proposal::createTicket(env, gw); json::Value tx = trust(gw, gw["USD"](0), alice, tfSetfAuth); tx[sfDelegate.jsonName] = bob.human(); tx[sfQualityOut.jsonName] = 100; env(proposal::create( bob, proposal::unsignedPayload(env, tx, ticketSeq), proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq)); } } // The target account must be able to authorize a transaction through a // SignerList, so a pseudo-account (here an AMM's) cannot be a target even // though it exists on-ledger (On-Chain Cosigner spec §5.3.2.5). void testPseudoTarget(FeatureBitset features) { testcase("reject proposal targeting a pseudo-account"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const proposer{"proposer"}; Account const alice{"alice"}; // the AMM creator Account const gw{"gw"}; Account const bob{"bob"}; // NOLINTNEXTLINE(readability-identifier-naming) auto const USD = gw["USD"]; env.fund(XRP(10000), proposer, alice, gw, bob); env.close(); env.trust(USD(1'000'000), alice); env.close(); env(pay(gw, alice, USD(10'000))); env.close(); AMM const amm(env, alice, XRP(1'000), USD(1'000), Ter(tesSUCCESS)); env.close(); // A well-formed Payment whose target is the AMM's pseudo-account. json::Value tx = pay(alice, bob, XRP(1)); tx[jss::Account] = toBase58(amm.ammAccount()); json::Value const proposedTx = proposal::unsignedPayload(env, tx, 1); env(proposal::create(proposer, proposedTx, proposal::expiration(env, 100s)), Ter(tecNO_PERMISSION), proposal::verify::create()); env.close(); } void testCreate(FeatureBitset features) { testcase("create proposal object"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); // The proposed transaction is stored unsigned: no signature fields and // an empty SigningPubKey. It is ticket-based so unrelated target account // activity cannot invalidate it while signatures are collected. json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); std::uint32_t const expiration = proposal::expiration(env, 100s); env(proposal::create(target, proposedTx, expiration), proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); if (!BEAST_EXPECT(sle)) return; BEAST_EXPECT(sle->getAccountID(sfOwner) == target.id()); BEAST_EXPECT(sle->getFieldU32(sfExpiration) == expiration); auto const stored = sle->getFieldObject(sfProposedTransaction); BEAST_EXPECT(stored.getAccountID(sfAccount) == target.id()); BEAST_EXPECT(stored.getFieldU32(sfSequence) == 0); BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq); BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty()); // The proposal reserves several owner increments against the proposer, // 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); } // A proposal carries a transaction of any type: what the proposal requires // of the payload — unsigned, ticket-based, fee fixed — is independent of // the transaction being proposed, so anything a target account's signer // list could authorize can be proposed for it. void testOtherTransactionTypes(FeatureBitset features) { testcase("proposals for other transaction types"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; Account const gw{"gw"}; // NOLINTNEXTLINE(readability-identifier-naming) auto const USD = gw["USD"]; env.fund(XRP(10000), target, bob, gw); env.close(); // One payload per transaction type, each straight from the generator // the ordinary tests for that type use. std::vector const payloads{ noop(target), // AccountSet offer(target, USD(1), XRP(1)), // OfferCreate trust(target, USD(1000)), // TrustSet signers(target, 1, {{bob, 1}}), // SignerListSet deposit::auth(target, bob), // DepositPreauth token::mint(target, 0), // NFTokenMint }; // A proposal is keyed by target and ticket, so each payload needs its // own ticket. std::uint32_t const firstTicketSeq = proposal::createTicket(env, target, static_cast(payloads.size())); std::uint32_t const expiration = proposal::expiration(env, 100s); for (std::size_t i = 0; i < payloads.size(); ++i) { std::uint32_t const ticketSeq = firstTicketSeq + static_cast(i); env(proposal::create( target, proposal::unsignedPayload(env, payloads[i], ticketSeq), expiration), proposal::verify::create()); env.close(); } // target owns one Ticket per payload plus one proposal per payload. BEAST_EXPECT( ownerCount(env, target) == payloads.size() * (1 + proposal::kProposalOwnerCount)); } // A proposed transaction may itself require an auxiliary co-signer beyond // its own Account: a LoanSet's Counterparty, or the Sponsor of an // account-level SponsorshipTransfer (On-Chain Cosigner spec §6.1, §6.6.3). That co-signature // field is collected later via TransactionProposalSign, so — just like // the ordinary signature fields — it must be absent, not required, at // creation time. void testAuxiliaryCoSignatureTypes(FeatureBitset features) { testcase("proposal for a transaction type with an auxiliary co-signature"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const borrower{"borrower"}; // the target account, proposing for itself Account const bob{"bob"}; // an arbitrary sponsor placeholder env.fund(XRP(10000), borrower, bob); env.close(); std::uint32_t const expiration = proposal::expiration(env, 100s); // LoanSet: the Counterparty's signature is collected later; it must // not be required up front. { std::uint32_t const ticketSeq = proposal::createTicket(env, borrower); json::Value const tx = proposal::unsignedPayload(env, loan::set(borrower, uint256{1}, 1'000), ticketSeq); env(proposal::create(borrower, tx, expiration), proposal::verify::create()); env.close(); } // SponsorshipTransfer (account-level reserve sponsorship): the // Sponsor's signature is likewise collected later. { std::uint32_t const ticketSeq = proposal::createTicket(env, borrower); json::Value tx = sponsor::transfer(borrower, tfSponsorshipCreate); tx[sfSponsor.getJsonName()] = bob.human(); tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve; env(proposal::create( borrower, proposal::unsignedPayload(env, tx, ticketSeq), expiration), proposal::verify::create()); env.close(); } } // The proposer holds the proposal's reserve until it is resolved. void testReserve(FeatureBitset features) { testcase("proposer reserve"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const alice{"alice"}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); proposal::authorizeProposer(env, target, alice); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); // Fund alice just short of the reserve the proposal requires. env.fund( env.current()->fees().accountReserve(proposal::kProposalOwnerCount, 1) - drops(1), alice); env.close(); std::uint32_t const expiration = proposal::expiration(env, 100s); json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); env(proposal::create(alice, proposedTx, expiration), Ter(tecINSUFFICIENT_RESERVE), proposal::verify::create()); env.close(); env(pay(bob, alice, XRP(10))); env.close(); env(proposal::create(alice, proposedTx, expiration), proposal::verify::create()); env.close(); } // The proposal's reserve can instead be sponsored: the reserve is charged // to the sponsor's account, and the ledger object records the sponsor, the // same as any other reserve-sponsorable object (TransactionProposalCreate // is on the reserve-sponsorship allow-list). void testSponsoredReserve(FeatureBitset features) { testcase("proposer reserve sponsored"); using namespace jtx; using namespace std::chrono_literals; // Reserve sponsorship requires the Sponsor amendment, independent of // Cosign: with Cosign enabled but Sponsor disabled, a proposal that // tries to attach a sponsor is rejected before it ever reaches the // reserve-sponsorship allow-list. { Env env{*this, features - featureSponsor}; Account const alice{"alice"}; Account const target{"target"}; Account const bob{"bob"}; Account const backer{"backer"}; env.fund(XRP(10000), alice, target, bob, backer); env.close(); proposal::authorizeProposer(env, target, alice); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer, spfSponsorReserve), Sig(sfSponsorSignature, backer), Ter(temDISABLED), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq)); } Env env{*this, features}; Account const alice{"alice"}; // the proposer Account const target{"target"}; // the account the proposal is for Account const bob{"bob"}; Account const backer{"backer"}; // sponsors alice's proposal reserve env.fund(XRP(10000), alice, target, bob, backer); env.close(); proposal::authorizeProposer(env, target, alice); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer, spfSponsorReserve), Sig(sfSponsorSignature, backer), proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); if (!BEAST_EXPECT(sle)) return; BEAST_EXPECT(sle->isFieldPresent(sfSponsor)); BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer.id()); // alice still owns the proposal — her OwnerCount reflects that, same // as an unsponsored proposal. What moves to the sponsor is the // reserve requirement itself, tracked separately: alice's owner count // is covered by backer's sponsorship rather than her own balance. BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); BEAST_EXPECT(ownerCount(env, backer) == 0); BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount); BEAST_EXPECT(sponsoringOwnerCount(env, backer) == proposal::kProposalOwnerCount); } // A proposal's sponsored reserve can be reassigned to a new sponsor // through SponsorshipTransfer, the same as any other reserve-sponsored // ledger entry. void testSponsorshipTransfer(FeatureBitset features) { testcase("proposer reserve sponsorship transferred"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const alice{"alice"}; // the proposer Account const target{"target"}; // the account the proposal is for Account const bob{"bob"}; Account const backer1{"backer1"}; // the original sponsor Account const backer2{"backer2"}; // the new sponsor env.fund(XRP(10000), alice, target, bob, backer1, backer2); env.close(); proposal::authorizeProposer(env, target, alice); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)), sponsor::As(backer1, spfSponsorReserve), Sig(sfSponsorSignature, backer1), proposal::verify::create()); env.close(); BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == proposal::kProposalOwnerCount); BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == 0); Keylet const proposalKeylet = keylet::txProposal(target.id(), targetTicketSeq); env(sponsor::transfer(alice, tfSponsorshipReassign, proposalKeylet.key), sponsor::As(backer2, spfSponsorReserve), Sig(sfSponsorSignature, backer2)); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); if (!BEAST_EXPECT(sle)) return; BEAST_EXPECT(sle->isFieldPresent(sfSponsor)); BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer2.id()); // alice's own OwnerCount is unaffected by the reassignment: only the // sponsor of the redirected reserve changes. BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount); BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount); BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == proposal::kProposalOwnerCount); } // TransactionProposalCreate's own transaction fee can be sponsored like // any other transaction's, independent of whether its reserve is // sponsored (On-Chain Cosigner spec sponsorship is orthogonal to fee // sponsorship). void testFeeSponsored(FeatureBitset features) { testcase("proposal creation fee sponsored"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; // the proposer, proposing for itself Account const bob{"bob"}; Account const backer{"backer"}; // sponsors target's transaction fee env.fund(XRP(10000), target, bob, backer); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); json::Value const proposedTx = proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq); auto const targetBalance = env.balance(target); auto const backerBalance = env.balance(backer); // A generous fixed fee: the exact amount isn't the point of this // test, only that the sponsor pays it instead of the proposer, so it // should comfortably clear the minimum even under local fee escalation // rather than assume the reference fee is some specific small value. STAmount const feeAmt = XRP(1); env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), Fee(feeAmt), sponsor::As(backer, spfSponsorFee), Sig(sfSponsorSignature, backer), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq)); BEAST_EXPECT(env.balance(target) == targetBalance); BEAST_EXPECT(env.balance(backer) == backerBalance - feeAmt); } // A proposed Batch holds several inner transactions and the signatures of // every account they touch, so it reserves more than an ordinary proposal. void testBatchReserve(FeatureBitset features) { testcase("proposed batch reserve"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); // Both inner transactions are the outer account's own, so no further // signatures will be collected for them. json::Value const proposedTx = proposal::unsignedBatch( env, target, targetTicketSeq, tfAllOrNothing, {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)), proposal::verify::create()); env.close(); // target owns its own Ticket plus the batch proposal. BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount); } // A multi-account Batch is the primary motivating case (On-Chain Cosigner spec §10): its inner // transactions touch accounts other than the outer one, so submitting it // directly would require a BatchSigners entry per participant. A proposal is // stored unsigned, so those signatures are collected on-ledger afterward and // the signer-presence match is skipped at creation time (On-Chain Cosigner spec §5.3.1.2). void testMultiAccountBatch(FeatureBitset features) { testcase("proposed multi-account batch"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; // outer account of the batch, proposing for itself Account const bob{"bob"}; // a distinct inner participant env.fund(XRP(10000), target, bob); env.close(); std::uint32_t const targetTicketSeq = proposal::createTicket(env, target); // One inner from the outer account, one from bob: bob is a required // signer, so a direct submission would need his BatchSigners entry. json::Value const proposedTx = proposal::unsignedBatch( env, target, targetTicketSeq, tfAllOrNothing, {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)), proposal::verify::create()); env.close(); auto const sle = proposal::entry(env, target, targetTicketSeq); if (!BEAST_EXPECT(sle)) return; // The proposal is stored without any BatchSigners: the participants' // signatures are collected later through TransactionProposalSign. auto const stored = sle->getFieldObject(sfProposedTransaction); BEAST_EXPECT(!stored.isFieldPresent(sfBatchSigners)); // target owns its own Ticket plus the batch proposal. BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount); } // A proposed Batch's inner preflight must receive TapProposal, or an // unsigned account-reserve SponsorshipTransfer is rejected at Create // (On-Chain Cosigner spec §6.1.1: an inner Sponsor is a collectable // signature slot). Other inner types that do not key on TapProposal keep // their existing preflight result. void testProposedBatchInnerSponsorship(FeatureBitset features) { testcase("proposed batch inner account-reserve SponsorshipTransfer"); using namespace jtx; using namespace std::chrono_literals; Env env{*this, features}; Account const target{"target"}; Account const bob{"bob"}; // named as Sponsor; signature collected later env.fund(XRP(10000), target, bob); env.close(); auto unsignedInnerSponsorship = [&](Account const& account) { json::Value tx = sponsor::transfer(account, tfSponsorshipCreate); tx[sfSponsor.getJsonName()] = bob.human(); tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve; return tx; }; // Payment (unaffected by TapProposal) plus an unsigned inner // account-reserve SponsorshipTransfer. Create succeeds only if // TapProposal reaches the inner. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); auto const seq = env.seq(target); json::Value const proposedTx = proposal::unsignedBatch( env, target, ticketSeq, tfAllOrNothing, {proposal::innerTx(pay(target, bob, XRP(1)), seq), proposal::innerTx(unsignedInnerSponsorship(target), seq + 1)}); env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), proposal::verify::create()); env.close(); BEAST_EXPECT(proposal::entry(env, target, ticketSeq)); } // Structural inner failures are unchanged: missing sfSponsor is still // temMALFORMED in SponsorshipTransfer::preflight, collapsed by Batch // to temINVALID_INNER_BATCH. TapProposal does not skip that. { std::uint32_t const ticketSeq = proposal::createTicket(env, target); auto const seq = env.seq(target); json::Value const proposedTx = proposal::unsignedBatch( env, target, ticketSeq, tfAllOrNothing, {proposal::innerTx(pay(target, bob, XRP(1)), seq), proposal::innerTx(sponsor::transfer(target, tfSponsorshipCreate), seq + 1)}); env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)), Ter(temINVALID_INNER_BATCH), proposal::verify::create()); env.close(); BEAST_EXPECT(!proposal::entry(env, target, ticketSeq)); } } void run() override { using namespace jtx; FeatureBitset const all{testableAmendments()}; testReserveCounts(); // Preflight testDisabled(all); testRejectedPayload(all); testRejectedSignatureFields(all); // Preclaim testPreclaim(all); testProposerAuthorization(all); testCorruptSignerList(all); testDelegatedProposedTx(all); testDelegatedGranularProposedTx(all); testPseudoTarget(all); // Apply testCreate(all); testOtherTransactionTypes(all); testAuxiliaryCoSignatureTypes(all); testReserve(all); testSponsoredReserve(all); testSponsorshipTransfer(all); testFeeSponsored(all); testBatchReserve(all); testMultiAccountBatch(all); testProposedBatchInnerSponsorship(all); } }; BEAST_DEFINE_TESTSUITE(TransactionProposalCreate, app, xrpl); } // namespace xrpl::test