xrpld
Loading...
Searching...
No Matches
TransactionProposalCreate_test.cpp
1#include <test/jtx/AMM.h>
2#include <test/jtx/Account.h>
3#include <test/jtx/Env.h>
4#include <test/jtx/TestHelpers.h>
5#include <test/jtx/amount.h>
6#include <test/jtx/delegate.h>
7#include <test/jtx/deposit.h>
8#include <test/jtx/fee.h>
9#include <test/jtx/flags.h>
10#include <test/jtx/multisign.h>
11#include <test/jtx/noop.h>
12#include <test/jtx/offer.h>
13#include <test/jtx/pay.h>
14#include <test/jtx/proposal.h>
15#include <test/jtx/sig.h>
16#include <test/jtx/sponsor.h>
17#include <test/jtx/ter.h>
18#include <test/jtx/ticket.h>
19#include <test/jtx/token.h>
20#include <test/jtx/trust.h>
21
22#include <xrpl/basics/strHex.h>
23#include <xrpl/beast/unit_test/suite.h>
24#include <xrpl/beast/utility/Journal.h>
25#include <xrpl/json/json_value.h>
26#include <xrpl/ledger/OpenView.h>
27#include <xrpl/protocol/AccountID.h>
28#include <xrpl/protocol/Feature.h>
29#include <xrpl/protocol/Indexes.h>
30#include <xrpl/protocol/Keylet.h>
31#include <xrpl/protocol/SField.h>
32#include <xrpl/protocol/STAmount.h>
33#include <xrpl/protocol/STArray.h>
34#include <xrpl/protocol/STLedgerEntry.h>
35#include <xrpl/protocol/STObject.h>
36#include <xrpl/protocol/SeqProxy.h>
37#include <xrpl/protocol/TER.h>
38#include <xrpl/protocol/TxFlags.h>
39#include <xrpl/protocol/jss.h>
40
41#include <chrono> // IWYU pragma: keep
42#include <cstddef>
43#include <cstdint>
44#include <functional>
45#include <memory>
46#include <string>
47#include <vector>
48
49namespace xrpl::test {
50
52{
53 void
55 {
56 testcase("proposal reserve");
57
58 using namespace jtx;
59
60 BEAST_EXPECT(proposal::kProposalOwnerCount == 5);
61 BEAST_EXPECT(proposal::kBatchProposalOwnerCount == 10);
62 }
63
64 // Nothing about the transaction is available before the amendment is
65 // active, not even to an otherwise valid proposal.
66 void
68 {
69 testcase("amendment disabled");
70
71 using namespace jtx;
72 using namespace std::chrono_literals;
73
74 Env env{*this, features - featureCosign};
75
76 Account const target{"target"};
77 Account const bob{"bob"};
78 env.fund(XRP(10000), target, bob);
79 env.close();
80
81 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
82
84 target,
85 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq),
86 proposal::expiration(env, 100s)),
89 env.close();
90
91 // Its own Ticket is the only thing target owns; the rejected
92 // proposal adds nothing on top of it.
93 BEAST_EXPECT(ownerCount(env, target) == 1);
94 }
95
96 // The proposed transaction must be a transaction that could be submitted on
97 // its own. Each case below takes an otherwise valid payload and breaks
98 // exactly one of those rules; the rules about its signature fields are
99 // covered by testRejectedSignatureFields.
100 void
102 {
103 testcase("reject payload that must not be stored");
104
105 using namespace jtx;
106 using namespace std::chrono_literals;
107
108 Env env{*this, features};
109
110 Account const target{"target"};
111 Account const bob{"bob"};
112 env.fund(XRP(10000), target, bob);
113 env.close();
114
115 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
116
117 std::uint32_t const expiration = proposal::expiration(env, 100s);
118
119 // A payload that is accepted as-is; every case starts from this.
120 auto payload = [&]() {
121 return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
122 };
123
124 // target's own Ticket is the only thing it owns throughout; a
125 // rejected proposal never adds anything on top of it.
126 auto reject = [&](json::Value const& proposedTx, TER expected) {
127 env(proposal::create(target, proposedTx, expiration),
128 Ter(expected),
130 env.close();
131 BEAST_EXPECT(ownerCount(env, target) == 1);
132 };
133
134 // An unrecognized TransactionType cannot even be constructed as an
135 // STTx (there is no format to validate it against), so it is
136 // rejected the same as any other malformed payload.
137 {
138 json::Value tx = payload();
139 tx[jss::TransactionType] = 65535;
140 reject(tx, temMALFORMED);
141 }
142
143 // A pseudo-transaction is never submittable by an account. This
144 // payload also carries Payment-shaped fields (Amount, Destination)
145 // that aren't part of EnableAmendment's own template, so it fails
146 // STTx construction before ever reaching our own isPseudoTx check.
147 {
148 json::Value tx = payload();
149 tx[jss::TransactionType] = jss::EnableAmendment;
150 reject(tx, temMALFORMED);
151 }
152
153 // An inner batch transaction bypasses the ordinary signature checks.
154 // The proposed transaction's own preflight rejects a standalone
155 // tfInnerBatchTxn (no enclosing Batch, no parentBatchId) with its own
156 // more specific code before reaching our own tfInnerBatchTxn check.
157 {
158 json::Value tx = payload();
159 tx[jss::Flags] = tfInnerBatchTxn;
160 reject(tx, temINVALID_INNER_BATCH);
161 }
162
163 // Proposals do not nest. This payload also isn't a valid instance of
164 // TransactionProposalCreate's own template (it lacks Expiration and
165 // ProposedTransaction), so it fails STTx construction before ever
166 // reaching our own isProposalTx check.
167 {
168 json::Value tx = payload();
169 tx[jss::TransactionType] = "TransactionProposalCreate";
170 reject(tx, temMALFORMED);
171 }
172
173 // Nor may a proposed Batch smuggle a nested proposal in as one of its
174 // own inner transactions. A second, ordinary inner transaction rides
175 // along only to satisfy Batch's own minimum of two inner
176 // transactions; it isn't itself the point of this case.
177 {
178 json::Value const nestedProposal =
179 proposal::create(target, payload(), proposal::expiration(env, 100s));
181 env,
182 target,
183 targetTicketSeq,
184 tfAllOrNothing,
185 {proposal::innerTx(nestedProposal, env.seq(target)),
186 proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)});
187 reject(tx, temINVALID);
188 }
189
190 // The proposed transaction must be ticket-based: a missing
191 // TicketSequence, or a live Sequence alongside it, is rejected.
192 {
193 json::Value tx = payload();
194 tx.removeMember(sfTicketSequence.getJsonName());
195 reject(tx, temSEQ_AND_TICKET);
196 }
197 {
198 json::Value tx = payload();
199 tx[jss::Sequence] = 1;
200 reject(tx, temSEQ_AND_TICKET);
201 }
202
203 // If this TransactionProposalCreate itself pays with a Ticket, and the
204 // proposed transaction targets that same account and Ticket, applying
205 // this transaction consumes the Ticket the proposal depends on before
206 // the proposal is even stored: it would be dead on arrival. target is
207 // proposing for itself here, so this is the Ticket it is about to pay
208 // with and the Ticket its own proposed payload names.
209 {
210 std::uint32_t const selfTicketSeq = proposal::createTicket(env, target);
211
212 json::Value const tx =
213 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), selfTicketSeq);
214 env(proposal::create(target, tx, expiration),
215 ticket::Use(selfTicketSeq),
217 env.close();
218 BEAST_EXPECT(!proposal::entry(env, target, selfTicketSeq));
219 BEAST_EXPECT(env.le(keylet::ticket(target.id(), SeqProxy::rawTicket(selfTicketSeq))));
220 BEAST_EXPECT(ownerCount(env, target) == 2);
221
222 // Consume the leftover Ticket so target's OwnerCount is back to
223 // just its original Ticket for the remaining cases below.
224 env(noop(target), ticket::Use(selfTicketSeq));
225 env.close();
226 BEAST_EXPECT(ownerCount(env, target) == 1);
227 }
228
229 // A payload that fails its own transaction type's preflight surfaces
230 // that type's own code, not a generic error (On-Chain Cosigner spec §5.3.1.2).
231 {
232 json::Value tx = payload();
233 tx[jss::Amount] = "0";
234 reject(tx, temBAD_AMOUNT);
235 }
236
237 // A payload that fails passesLocalChecks (On-Chain Cosigner spec
238 // §5.3.1 rule 2) is rejected as temMALFORMED. An oversized Memos
239 // array trips isMemoOkay; no transactor preflight step bounds
240 // memo size.
241 {
242 json::Value tx = payload();
243 tx[sfMemos.jsonName][0u][sfMemo.jsonName][sfMemoData.jsonName] =
244 strHex(std::string(1100, 'A')); // > 1024 bytes serialized
245 reject(tx, temMALFORMED);
246 }
247
248 // Expiration must be present and non-zero.
249 {
250 env(proposal::create(target, payload(), 0),
253 env.close();
254 BEAST_EXPECT(ownerCount(env, target) == 1);
255 }
256 }
257
258 // A proposal is stored in unsigned canonical form: an empty SigningPubKey
259 // and no signature field whatsoever. Signatures may only ever arrive
260 // through TransactionProposalSign, so a payload is rejected for carrying a
261 // signature container at all — whatever that container happens to hold.
262 // Each container below is therefore filled every way it could be,
263 // including combinations that could never verify: an empty container, a
264 // key with no signature, a signature with no key, and a signature next to
265 // the empty SigningPubKey the canonical form requires.
266 //
267 // The rejection code is not uniform, though. A fill that leaves actual
268 // signature bytes behind (a non-empty TxnSignature, or one inside a
269 // Signers entry) is, for some containers, intercepted before ever
270 // reaching our own hasSignatureField check: the payload's own top-level
271 // fields are checked by Transactor::preflight2's dry-run simulate-key
272 // logic, and LoanSet forwards its CounterpartySignature through that same
273 // logic, both yielding temINVALID instead. SponsorSignature and
274 // BatchSigners are not inspected that way — only their presence is
275 // checked elsewhere — so they always reach our own check regardless of
276 // what they hold.
277 void
279 {
280 testcase("reject payload carrying a signature");
281
282 using namespace jtx;
283 using namespace std::chrono_literals;
284
285 Env env{*this, features};
286
287 Account const target{"target"};
288 Account const bob{"bob"};
289 env.fund(XRP(10000), target, bob);
290 env.close();
291
292 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
293
294 std::uint32_t const expiration = proposal::expiration(env, 100s);
295
296 std::string const key = strHex(bob.pk().slice());
297 std::string const sig = "DEADBEEF";
298
299 // The payloads every case starts from, each accepted as-is. Two cases
300 // below would otherwise build the same payload, and the same proposal
301 // cannot be submitted twice — the second is turned away as a duplicate
302 // rather than judged again — so each call pays a different amount.
303 // Nothing here turns on the amount.
304 std::uint32_t paid = 0;
305 auto payment = [&]() {
306 return proposal::unsignedPayload(env, pay(target, bob, drops(++paid)), targetTicketSeq);
307 };
308 auto sponsoredPayment = [&]() {
309 // bob is just standing in for an arbitrary sponsor here; every case
310 // is rejected for carrying a signature field before the sponsor
311 // itself is ever examined.
312 json::Value tx = pay(target, bob, drops(++paid));
313 tx[sfSponsor.getJsonName()] = bob.human();
314 tx[sfSponsorFlags.getJsonName()] = spfSponsorFee;
315 return proposal::unsignedPayload(env, tx, targetTicketSeq);
316 };
317 auto loanSet = [&]() {
318 json::Value tx = loan::set(target, UInt256{1}, 1'000 + ++paid);
319 tx[sfCounterparty.getJsonName()] = bob.human();
320 return proposal::unsignedPayload(env, tx, targetTicketSeq);
321 };
322 auto batchTx = [&]() {
324 env,
325 target,
326 targetTicketSeq,
327 tfAllOrNothing,
328 {proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target)),
329 proposal::innerTx(pay(target, bob, drops(++paid)), env.seq(target) + 1)});
330 };
331
332 // target's own Ticket is the only thing it owns throughout; a
333 // rejected proposal never adds anything on top of it.
334 auto reject = [&](json::Value const& proposedTx, TER expected) {
335 env(proposal::create(target, proposedTx, expiration),
336 Ter(expected),
338 env.close();
339 BEAST_EXPECT(ownerCount(env, target) == 1);
340 };
341
342 // Every way of filling in a signature. The payload's own signature
343 // fields and a co-signature object hold the same three members, so the
344 // same fills apply to both. `signs` marks a fill that leaves actual
345 // signature bytes behind, which some containers' own dry-run
346 // simulate-key check reacts to (see the class comment above).
347 struct Fill
348 {
350 bool signs;
351 };
352
353 std::vector<Fill> const fills{
354 {.apply = [&](json::Value& o) { o[jss::SigningPubKey] = key; }, .signs = false},
355 {.apply = [&](json::Value& o) { o[sfTxnSignature.getJsonName()] = sig; },
356 .signs = true},
357 {.apply =
358 [&](json::Value& o) {
359 o[jss::SigningPubKey] = "";
360 o[sfTxnSignature.getJsonName()] = sig;
361 },
362 .signs = true},
363 // Signed the ordinary way, which is the likeliest way one of these
364 // arrives here.
365 {.apply =
366 [&](json::Value& o) {
367 o[jss::SigningPubKey] = key;
368 o[sfTxnSignature.getJsonName()] = sig;
369 },
370 .signs = true},
371 // Multi-signed: the signer's own key is empty and the signatures
372 // sit in a nested Signers array. Each entry needs all three of
373 // Account, SigningPubKey and TxnSignature to parse at all, so only
374 // their values can vary.
375 {.apply =
376 [&](json::Value& o) {
377 o[jss::SigningPubKey] = "";
378 auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()];
379 signer[jss::Account] = bob.human();
380 signer[jss::SigningPubKey] = key;
381 signer[sfTxnSignature.getJsonName()] = sig;
382 },
383 .signs = true},
384 {.apply =
385 [&](json::Value& o) {
386 o[jss::SigningPubKey] = "";
387 auto& signer = o[sfSigners.getJsonName()][0u][sfSigner.getJsonName()];
388 signer[jss::Account] = bob.human();
389 signer[jss::SigningPubKey] = "";
390 signer[sfTxnSignature.getJsonName()] = sig;
391 },
392 .signs = true},
393 };
394
395 // Every place a signature could sit, on a payload of a type that
396 // carries it: a Counterparty's signature belongs to a LoanSet and
397 // BatchSigners to a Batch, while a Sponsor's signature and the
398 // payload's own signature fields sit on any transaction. A signature
399 // is no more storable for being a field its transaction type expects
400 // (On-Chain Cosigner spec §6.1, §6.6.3). `checksSignatureContent`
401 // marks a place whose own preflight forwards the container through a
402 // dry-run simulate-key check, the same as the payload's own top-level
403 // fields.
404 struct Place
405 {
406 std::function<json::Value()> payload;
408 bool checksSignatureContent;
409 };
410
411 std::vector<Place> const places{
412 {.payload = payment,
413 .at = [](json::Value& tx) -> json::Value& { return tx; },
414 .checksSignatureContent = true},
415 {.payload = loanSet,
416 .at = [](json::Value& tx) -> json::Value& {
417 auto& o = tx[sfCounterpartySignature.getJsonName()];
419 return o;
420 },
421 .checksSignatureContent = true},
422 {.payload = sponsoredPayment,
423 .at = [](json::Value& tx) -> json::Value& {
424 auto& o = tx[sfSponsorSignature.getJsonName()];
426 return o;
427 },
428 .checksSignatureContent = false},
429 // A BatchSigners entry names the account it speaks for; the other
430 // two co-signatures are fixed by the transaction they belong to and
431 // do not.
432 {.payload = batchTx,
433 .at = [&](json::Value& tx) -> json::Value& {
434 auto& o = tx[sfBatchSigners.getJsonName()][0u][sfBatchSigner.getJsonName()];
435 o[jss::Account] = bob.human();
436 return o;
437 },
438 .checksSignatureContent = false},
439 };
440
441 for (auto const& place : places)
442 {
443 for (auto const& fill : fills)
444 {
445 json::Value tx = place.payload();
446 fill.apply(place.at(tx));
447 reject(tx, place.checksSignatureContent && fill.signs ? temINVALID : temBAD_SIGNER);
448 }
449 }
450
451 // Every place but the payload itself: a co-signature object is
452 // disqualifying by its presence alone, so each is rejected left empty
453 // too. The payload's own fields have no such case — left alone they are
454 // the canonical form. An empty container never trips a simulate-key
455 // check, so this is temBAD_SIGNER regardless of checksSignatureContent.
456 for (std::size_t i = 1; i < places.size(); ++i)
457 {
458 json::Value tx = places[i].payload();
459 places[i].at(tx);
460 reject(tx, temBAD_SIGNER);
461 }
462
463 // Nor does the payload have a counterpart for an absent SigningPubKey:
464 // in a co-signature object an absent member is just an unfilled one,
465 // but at the top level it is not the same as an empty one, with or
466 // without a signature beside it. SigningPubKey is a required common
467 // field, so its absence means proposedTx isn't a valid instance of its
468 // own type; that's caught while constructing it as an STTx, before
469 // reaching our own hasEmptySigningPubKey check.
470 {
471 json::Value tx = payment();
472 tx.removeMember(jss::SigningPubKey);
473 reject(tx, temMALFORMED);
474 }
475 {
476 json::Value tx = payment();
477 tx.removeMember(jss::SigningPubKey);
478 tx[sfTxnSignature.getJsonName()] = sig;
479 reject(tx, temMALFORMED);
480 }
481 }
482
483 // A proposal that could never be completed must not be stored, and a
484 // target-and-ticket pair may hold at most one proposal.
485 void
487 {
488 testcase("reject proposal that cannot be completed");
489
490 using namespace jtx;
491 using namespace std::chrono_literals;
492
493 Env env{*this, features};
494
495 Account const target{"target"};
496 Account const bob{"bob"};
497 Account const carol{"carol"}; // never funded
498 env.fund(XRP(10000), target, bob);
499 env.close();
500
501 std::uint32_t const firstTicketSeq = proposal::createTicket(env, target, 3);
502
503 std::uint32_t const expiration = proposal::expiration(env, 100s);
504
505 auto payload = [&](std::uint32_t ticketSeq) {
506 return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq);
507 };
508
509 // target's three Tickets are owned throughout, so its OwnerCount
510 // never drops below 3; each successful proposal adds kProposalOwnerCount
511 // on top of that baseline.
512
513 // The proposal's own expiration has already passed.
514 {
515 env(proposal::create(target, payload(firstTicketSeq), proposal::expiration(env, 0s)),
518 env.close();
519 BEAST_EXPECT(ownerCount(env, target) == 3);
520 }
521
522 // The proposed transaction's own ledger bound has passed: the ordinary
523 // path would reject it with tefMAX_LEDGER, so it can never complete.
524 {
525 json::Value tx = payload(firstTicketSeq);
526 tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq() - 1;
527 env(proposal::create(target, tx, expiration),
530 env.close();
531 BEAST_EXPECT(ownerCount(env, target) == 3);
532 }
533
534 // A LastLedgerSequence equal to the current ledger leaves no window to
535 // collect signatures before the proposed transaction's own bound
536 // passes, so it is rejected the same as one already in the past
537 // (On-Chain Cosigner spec §5.3.2.2).
538 {
539 json::Value tx = payload(firstTicketSeq);
540 tx[sfLastLedgerSequence.getJsonName()] = env.current()->seq();
541 env(proposal::create(target, tx, expiration),
544 env.close();
545 BEAST_EXPECT(ownerCount(env, target) == 3);
546 }
547
548 // With no ledger bound on the proposed transaction, the proposal is
549 // created normally.
550 {
551 env(proposal::create(target, payload(firstTicketSeq), expiration),
553 env.close();
554 BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount);
555 }
556
557 // The target and ticket already carry a proposal.
558 {
559 env(proposal::create(target, payload(firstTicketSeq), expiration),
562 env.close();
563 BEAST_EXPECT(ownerCount(env, target) == 3 + proposal::kProposalOwnerCount);
564 }
565
566 // A different ticket of the same target is a different proposal.
567 {
568 env(proposal::create(target, payload(firstTicketSeq + 1), expiration),
570 env.close();
571 BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount));
572 }
573
574 // The target account does not exist, so it can never sign. target
575 // itself is just standing in here as an arbitrary funded submitter —
576 // the account under test is carol, the (nonexistent) target.
577 {
579 target, proposal::unsignedPayload(env, pay(carol, bob, XRP(1)), 1), expiration),
582 env.close();
583 BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount));
584 }
585
586 // The referenced ticket does not exist: the proposal would reserve a
587 // ticket that was never created (On-Chain Cosigner spec §5.3.2).
588 {
589 std::uint32_t const noSuchTicketSeq = firstTicketSeq + 100;
590 env(proposal::create(target, payload(noSuchTicketSeq), expiration),
593 env.close();
594 BEAST_EXPECT(ownerCount(env, target) == 3 + (2 * proposal::kProposalOwnerCount));
595 }
596 }
597
598 // Only the target account itself, or an account on its SignerList, may
599 // create a proposal against it. Otherwise any unrelated account could
600 // spam or squat the target's Tickets with unwanted proposals (On-Chain
601 // Cosigner V1 authorization scope).
602 void
604 {
605 testcase("reject proposal from an unauthorized proposer");
606
607 using namespace jtx;
608 using namespace std::chrono_literals;
609
610 Env env{*this, features};
611
612 Account const target{"target"};
613 Account const signer{"signer"};
614 Account const stranger{"stranger"};
615 Account const bob{"bob"};
616 env.fund(XRP(10000), target, signer, stranger, bob);
617 env.close();
618
619 env(signers(target, 1, {{signer, 1}}));
620 env.close();
621
622 auto payload = [&](std::uint32_t ticketSeq) {
623 return proposal::unsignedPayload(env, pay(target, bob, XRP(1)), ticketSeq);
624 };
625
626 // The target account itself needs no SignerList entry.
627 {
628 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
629 env(proposal::create(target, payload(ticketSeq), proposal::expiration(env, 100s)),
631 env.close();
632 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
633 }
634
635 // An account on the target's SignerList may propose for it.
636 {
637 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
638 env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)),
640 env.close();
641 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
642 }
643
644 // An account that is neither the target nor on its SignerList may not.
645 {
646 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
647 env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)),
650 env.close();
651 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
652 BEAST_EXPECT(ownerCount(env, stranger) == 0);
653 }
654
655 // A target with no SignerList at all may only be proposed for by
656 // itself.
657 {
658 Account const bare{"bare"};
659 env.fund(XRP(10000), bare);
660 env.close();
661
662 std::uint32_t const ticketSeq = proposal::createTicket(env, bare);
664 stranger,
665 proposal::unsignedPayload(env, pay(bare, bob, XRP(1)), ticketSeq),
666 proposal::expiration(env, 100s)),
669 env.close();
670 BEAST_EXPECT(!proposal::entry(env, bare, ticketSeq));
671 }
672
673 // A SignerList with several entries authorizes every one of them, not
674 // just the first, matching a real-world multi-signer setup rather
675 // than only ever exercising a single-signer list.
676 {
677 Account const s1{"s1"};
678 Account const s2{"s2"};
679 Account const s3{"s3"};
680 Account const s4{"s4"};
681 Account const s5{"s5"};
682 env.fund(XRP(10000), s1, s2, s3, s4, s5);
683 env.close();
684
685 env(signers(target, 3, {{s1, 1}, {s2, 1}, {s3, 1}, {s4, 1}, {s5, 1}}));
686 env.close();
687
688 for (Account const& s : {s1, s2, s3, s4, s5})
689 {
690 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
691 env(proposal::create(s, payload(ticketSeq), proposal::expiration(env, 100s)),
693 env.close();
694 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
695 }
696
697 // The old SignerList's sole signer is no longer on the new one.
698 {
699 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
700 env(proposal::create(signer, payload(ticketSeq), proposal::expiration(env, 100s)),
703 env.close();
704 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
705 }
706
707 // An unrelated account still may not.
708 {
709 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
710 env(proposal::create(stranger, payload(ticketSeq), proposal::expiration(env, 100s)),
713 env.close();
714 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
715 }
716 }
717 }
718
719 // An on-ledger ltSIGNER_LIST that cannot be read as signer entries is
720 // unexpected ledger state, not a malformed transaction. preclaim must
721 // surface tefBAD_LEDGER (not temMALFORMED, not tefINTERNAL which is
722 // reserved for truly unreachable paths, and not the tefEXCEPTION that
723 // applySteps would wrap an uncaught throw with).
724 //
725 // SignerEntries::deserialize returns unexpected(temMALFORMED) when
726 // sfSignerEntries is missing or an element is not named sfSignerEntry.
727 // It still throws from STObject accessors when an sfSignerEntry is
728 // missing required fields (getAccountID → "Field not found: Account").
729 // Do not close() after the synthetic corruption: a closed ledger would
730 // drop the overlay and restore a well-formed list.
731 void
733 {
734 testcase("unparseable on-ledger SignerList is tefBAD_LEDGER");
735
736 using namespace jtx;
737 using namespace std::chrono_literals;
738
739 auto setup = [&](Env& env, Account const& target, Account const& signer) {
740 env.fund(XRP(10000), target, signer);
741 env.close();
742 env(signers(target, 1, {{signer, 1}}));
743 env.close();
744 // Ticket first: createTicket closes, which would drop a later
745 // open-ledger overlay and restore a well-formed SignerList.
746 return proposal::createTicket(env, target);
747 };
748
749 auto proposeAsSigner =
750 [&](Env& env, Account const& target, Account const& signer, std::uint32_t ticketSeq) {
752 signer,
753 proposal::unsignedPayload(env, pay(target, signer, XRP(1)), ticketSeq),
754 proposal::expiration(env, 100s)),
757 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
758 };
759
760 {
761 Env env{*this, features};
762 Account const target{"targetMissing"};
763 Account const signer{"signerMissing"};
764 std::uint32_t const ticketSeq = setup(env, target, signer);
765
766 auto const signerListKeylet = keylet::signerList(target.id());
767 BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
768 auto const sle = view.read(signerListKeylet);
769 if (!sle)
770 return false;
771 auto replacement = std::make_shared<SLE>(*sle);
772 if (!replacement->delField(sfSignerEntries))
773 return false;
774 view.rawReplace(replacement);
775 return true;
776 }));
777 BEAST_EXPECT(env.le(signerListKeylet));
778
779 proposeAsSigner(env, target, signer, ticketSeq);
780 }
781
782 {
783 Env env{*this, features};
784 Account const target{"targetBadEntry"};
785 Account const signer{"signerBadEntry"};
786 std::uint32_t const ticketSeq = setup(env, target, signer);
787
788 auto const signerListKeylet = keylet::signerList(target.id());
789 BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
790 auto const sle = view.read(signerListKeylet);
791 if (!sle)
792 return false;
793 auto replacement = std::make_shared<SLE>(*sle);
794 STArray badEntries;
795 badEntries.pushBack(STObject{sfSigner});
796 replacement->setFieldArray(sfSignerEntries, badEntries);
797 view.rawReplace(replacement);
798 return true;
799 }));
800 BEAST_EXPECT(env.le(signerListKeylet));
801
802 proposeAsSigner(env, target, signer, ticketSeq);
803 }
804
805 {
806 Env env{*this, features};
807 Account const target{"targetMissingAccount"};
808 Account const signer{"signerMissingAccount"};
809 std::uint32_t const ticketSeq = setup(env, target, signer);
810
811 auto const signerListKeylet = keylet::signerList(target.id());
812 BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
813 auto const sle = view.read(signerListKeylet);
814 if (!sle)
815 return false;
816 auto replacement = std::make_shared<SLE>(*sle);
817 STArray badEntries;
818 // Right inner name, but no sfAccount: deserialize calls
819 // getAccountID and throws (Field not found), which the
820 // catch maps to tefBAD_LEDGER.
821 badEntries.pushBack(STObject{sfSignerEntry});
822 replacement->setFieldArray(sfSignerEntries, badEntries);
823 view.rawReplace(replacement);
824 return true;
825 }));
826 BEAST_EXPECT(env.le(signerListKeylet));
827
828 proposeAsSigner(env, target, signer, ticketSeq);
829 }
830 }
831
832 // The target account may delegate authority over the proposed
833 // transaction's own type to another account (Permission Delegation,
834 // XLS-75); if it does, that delegate — or an account on the delegate's
835 // own SignerList — may also create the proposal, since it will need to
836 // help complete the proposed transaction's own authorization anyway.
837 // Naming an account as Delegate in the proposed transaction is not
838 // itself trusted: a real DelegateSet grant is required.
839 void
841 {
842 testcase("proposer authorized through a delegated proposed txn");
843
844 using namespace jtx;
845 using namespace std::chrono_literals;
846
847 Env env{*this, features};
848
849 Account const target{"target"};
850 Account const delegateAcct{"delegateAcct"};
851 Account const ds1{"ds1"}; // on delegateAcct's own SignerList
852 Account const ds2{"ds2"}; // on delegateAcct's own SignerList
853 Account const stranger{"stranger"};
854 Account const bob{"bob"};
855 env.fund(XRP(10000), target, delegateAcct, ds1, ds2, stranger, bob);
856 env.close();
857
858 auto delegatedPayload = [&](std::uint32_t ticketSeq) {
859 json::Value tx = pay(target, bob, XRP(1));
860 tx[sfDelegate.jsonName] = delegateAcct.human();
861 return proposal::unsignedPayload(env, tx, ticketSeq);
862 };
863
864 // Without a real DelegateSet grant, naming an account as Delegate in
865 // the proposed transaction does not authorize it.
866 {
867 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
869 delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)),
872 env.close();
873 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
874 }
875
876 // The target grants delegateAcct permission over Payment transactions.
877 env(delegate::set(target, delegateAcct, {"Payment"}));
878 env.close();
879
880 // The delegate itself may now create the proposal.
881 {
882 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
884 delegateAcct, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)),
886 env.close();
887 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
888 }
889
890 // An account on the delegate's own SignerList may likewise create it.
891 {
892 env(signers(delegateAcct, 1, {{ds1, 1}, {ds2, 1}}));
893 env.close();
894
895 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
896 env(proposal::create(ds1, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)),
898 env.close();
899 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
900 }
901
902 // An account with no relationship to the target or the delegate is
903 // still rejected.
904 {
905 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
907 stranger, delegatedPayload(ticketSeq), proposal::expiration(env, 100s)),
910 env.close();
911 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
912 }
913 }
914
915 // A delegate holding only a granular permission (XLS-75) that would
916 // authorize submitting the proposed transaction may also create a
917 // proposal for it. A granular grant that fails checkGranularSandbox
918 // still cannot.
919 void
921 {
922 testcase("proposer authorized through granular delegate permission");
923
924 using namespace jtx;
925 using namespace std::chrono_literals;
926
927 Env env{*this, features};
928
929 Account const gw{"gw"}; // issuer / proposed-tx Account
930 Account const alice{"alice"}; // holder of the trust line being authorized
931 Account const bob{"bob"}; // delegate with TrustlineAuthorize only
932 env.fund(XRP(10000), gw, alice, bob);
933 env(fset(gw, asfRequireAuth));
934 env.close();
935
936 env(trust(alice, gw["USD"](50)));
937 env.close();
938 env(delegate::set(gw, bob, {"TrustlineAuthorize"}));
939 env.close();
940
941 auto delegatedTrustSet = [&](std::uint32_t ticketSeq, std::uint32_t flags) {
942 json::Value tx = trust(gw, gw["USD"](0), alice, flags);
943 tx[sfDelegate.jsonName] = bob.human();
944 return proposal::unsignedPayload(env, tx, ticketSeq);
945 };
946
947 // TrustlineAuthorize is sufficient for a tfSetfAuth TrustSet against
948 // an existing line whose limit is unchanged — the same shape that
949 // submits successfully under invokeCheckPermission.
950 {
951 std::uint32_t const ticketSeq = proposal::createTicket(env, gw);
953 bob, delegatedTrustSet(ticketSeq, tfSetfAuth), proposal::expiration(env, 100s)),
955 env.close();
956 BEAST_EXPECT(proposal::entry(env, gw, ticketSeq));
957 }
958
959 // tfSetFreeze is not in TrustlineAuthorize's sandbox.
960 {
961 std::uint32_t const ticketSeq = proposal::createTicket(env, gw);
963 bob,
964 delegatedTrustSet(ticketSeq, tfSetFreeze),
965 proposal::expiration(env, 100s)),
968 env.close();
969 BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq));
970 }
971
972 // sfQualityOut is a valid TrustSet field but not in the granular
973 // template, so checkGranularSandbox rejects it.
974 {
975 std::uint32_t const ticketSeq = proposal::createTicket(env, gw);
976 json::Value tx = trust(gw, gw["USD"](0), alice, tfSetfAuth);
977 tx[sfDelegate.jsonName] = bob.human();
978 tx[sfQualityOut.jsonName] = 100;
980 bob,
981 proposal::unsignedPayload(env, tx, ticketSeq),
982 proposal::expiration(env, 100s)),
985 env.close();
986 BEAST_EXPECT(!proposal::entry(env, gw, ticketSeq));
987 }
988 }
989
990 // Same failure mode as testCorruptSignerList, but reached through the
991 // delegate branch: preclaim looks up the delegate's own SignerList when
992 // the proposer is neither the target, on the target's SignerList, nor the
993 // delegate itself. If the delegate's SignerList is unparseable, preclaim
994 // must surface tefBAD_LEDGER — exercising the second isAuthorizedFor call
995 // that runs against the delegate rather than the target.
996 void
998 {
999 testcase("unparseable delegate SignerList is tefBAD_LEDGER");
1000
1001 using namespace jtx;
1002 using namespace std::chrono_literals;
1003
1004 Env env{*this, features};
1005
1006 Account const target{"target"};
1007 Account const delegateAcct{"delegateAcct"};
1008 Account const ds1{"ds1"}; // on delegateAcct's own SignerList, not target's
1009 Account const bob{"bob"};
1010 env.fund(XRP(10000), target, delegateAcct, ds1, bob);
1011 env.close();
1012
1013 // Grant delegate Payment permission for target; give delegate its own
1014 // SignerList so that isAuthorizedFor(delegateAccount) actually reads
1015 // and deserializes it. Do not give target a SignerList: proposer ds1
1016 // must fail isAuthorizedFor(target) before ever reaching the delegate
1017 // branch.
1018 env(delegate::set(target, delegateAcct, {"Payment"}));
1019 env(signers(delegateAcct, 1, {{ds1, 1}}));
1020 env.close();
1021
1022 // Ticket first: createTicket closes, which would drop a later
1023 // open-ledger overlay and restore a well-formed SignerList.
1024 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
1025
1026 // Corrupt delegate's SignerList in the open ledger overlay only. Do
1027 // not close() afterward: a closed ledger would drop the overlay.
1028 auto const delegateSignerListKeylet = keylet::signerList(delegateAcct.id());
1029 BEAST_EXPECT(env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
1030 auto const sle = view.read(delegateSignerListKeylet);
1031 if (!sle)
1032 return false;
1033 auto replacement = std::make_shared<SLE>(*sle);
1034 // Right inner name, but no sfAccount: deserialize calls
1035 // getAccountID and throws (Field not found), which the catch
1036 // maps to tefBAD_LEDGER.
1037 STArray badEntries;
1038 badEntries.pushBack(STObject{sfSignerEntry});
1039 replacement->setFieldArray(sfSignerEntries, badEntries);
1040 view.rawReplace(replacement);
1041 return true;
1042 }));
1043 BEAST_EXPECT(env.le(delegateSignerListKeylet));
1044
1045 json::Value tx = pay(target, bob, XRP(1));
1046 tx[sfDelegate.jsonName] = delegateAcct.human();
1047 env(proposal::create(
1048 ds1,
1049 proposal::unsignedPayload(env, tx, ticketSeq),
1050 proposal::expiration(env, 100s)),
1053 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
1054 }
1055
1056 // The target account must be able to authorize a transaction through a
1057 // SignerList, so a pseudo-account (here an AMM's) cannot be a target even
1058 // though it exists on-ledger (On-Chain Cosigner spec §5.3.2.5).
1059 void
1061 {
1062 testcase("reject proposal targeting a pseudo-account");
1063
1064 using namespace jtx;
1065 using namespace std::chrono_literals;
1066
1067 Env env{*this, features};
1068
1069 Account const proposer{"proposer"};
1070 Account const alice{"alice"}; // the AMM creator
1071 Account const gw{"gw"};
1072 Account const bob{"bob"};
1073 // NOLINTNEXTLINE(readability-identifier-naming)
1074 auto const USD = gw["USD"];
1075 env.fund(XRP(10000), proposer, alice, gw, bob);
1076 env.close();
1077 env.trust(USD(1'000'000), alice);
1078 env.close();
1079 env(pay(gw, alice, USD(10'000)));
1080 env.close();
1081
1082 AMM const amm(env, alice, XRP(1'000), USD(1'000), Ter(tesSUCCESS));
1083 env.close();
1084
1085 // A well-formed Payment whose target is the AMM's pseudo-account.
1086 json::Value tx = pay(alice, bob, XRP(1));
1087 tx[jss::Account] = toBase58(amm.ammAccount());
1088 json::Value const proposedTx = proposal::unsignedPayload(env, tx, 1);
1089
1090 env(proposal::create(proposer, proposedTx, proposal::expiration(env, 100s)),
1093 env.close();
1094 }
1095
1096 void
1098 {
1099 testcase("create proposal object");
1100
1101 using namespace jtx;
1102 using namespace std::chrono_literals;
1103
1104 Env env{*this, features};
1105
1106 Account const target{"target"};
1107 Account const bob{"bob"};
1108 env.fund(XRP(10000), target, bob);
1109 env.close();
1110
1111 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1112
1113 // The proposed transaction is stored unsigned: no signature fields and
1114 // an empty SigningPubKey. It is ticket-based so unrelated target account
1115 // activity cannot invalidate it while signatures are collected.
1116 json::Value const proposedTx =
1117 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1118
1119 std::uint32_t const expiration = proposal::expiration(env, 100s);
1120
1121 env(proposal::create(target, proposedTx, expiration), proposal::verify::create());
1122 env.close();
1123
1124 auto const sle = proposal::entry(env, target, targetTicketSeq);
1125 if (!BEAST_EXPECT(sle))
1126 return;
1127
1128 BEAST_EXPECT(sle->getAccountID(sfOwner) == target.id());
1129 BEAST_EXPECT(sle->getFieldU32(sfExpiration) == expiration);
1130
1131 auto const stored = sle->getFieldObject(sfProposedTransaction);
1132 BEAST_EXPECT(stored.getAccountID(sfAccount) == target.id());
1133 BEAST_EXPECT(stored.getFieldU32(sfSequence) == 0);
1134 BEAST_EXPECT(stored.getFieldU32(sfTicketSequence) == targetTicketSeq);
1135 BEAST_EXPECT(stored.getFieldVL(sfSigningPubKey).empty());
1136
1137 // The proposal reserves several owner increments against the proposer,
1138 // which proposal::verify::create() checks. Here target is both: it owns
1139 // the Ticket used by the proposed transaction, and it owns the proposal
1140 // itself since it is proposing for its own account.
1141 BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kProposalOwnerCount);
1142 }
1143
1144 // A proposal carries a transaction of any type: what the proposal requires
1145 // of the payload — unsigned, ticket-based, fee fixed — is independent of
1146 // the transaction being proposed, so anything a target account's signer
1147 // list could authorize can be proposed for it.
1148 void
1150 {
1151 testcase("proposals for other transaction types");
1152
1153 using namespace jtx;
1154 using namespace std::chrono_literals;
1155
1156 Env env{*this, features};
1157
1158 Account const target{"target"};
1159 Account const bob{"bob"};
1160 Account const gw{"gw"};
1161 // NOLINTNEXTLINE(readability-identifier-naming)
1162 auto const USD = gw["USD"];
1163 env.fund(XRP(10000), target, bob, gw);
1164 env.close();
1165
1166 // One payload per transaction type, each straight from the generator
1167 // the ordinary tests for that type use.
1168 std::vector<json::Value> const payloads{
1169 noop(target), // AccountSet
1170 offer(target, USD(1), XRP(1)), // OfferCreate
1171 trust(target, USD(1000)), // TrustSet
1172 signers(target, 1, {{bob, 1}}), // SignerListSet
1173 deposit::auth(target, bob), // DepositPreauth
1174 token::mint(target, 0), // NFTokenMint
1175 };
1176
1177 // A proposal is keyed by target and ticket, so each payload needs its
1178 // own ticket.
1179 std::uint32_t const firstTicketSeq =
1180 proposal::createTicket(env, target, static_cast<std::uint32_t>(payloads.size()));
1181 std::uint32_t const expiration = proposal::expiration(env, 100s);
1182
1183 for (std::size_t i = 0; i < payloads.size(); ++i)
1184 {
1185 std::uint32_t const ticketSeq = firstTicketSeq + static_cast<std::uint32_t>(i);
1186 env(proposal::create(
1187 target, proposal::unsignedPayload(env, payloads[i], ticketSeq), expiration),
1189 env.close();
1190 }
1191
1192 // target owns one Ticket per payload plus one proposal per payload.
1193 BEAST_EXPECT(
1194 ownerCount(env, target) == payloads.size() * (1 + proposal::kProposalOwnerCount));
1195 }
1196
1197 // A proposed transaction may itself require an auxiliary co-signer beyond
1198 // its own Account: a LoanSet's Counterparty, or the Sponsor of an
1199 // account-level SponsorshipTransfer (On-Chain Cosigner spec §6.1, §6.6.3). That co-signature
1200 // field is collected later via TransactionProposalSign, so — just like
1201 // the ordinary signature fields — it must be absent, not required, at
1202 // creation time.
1203 void
1205 {
1206 testcase("proposal for a transaction type with an auxiliary co-signature");
1207
1208 using namespace jtx;
1209 using namespace std::chrono_literals;
1210
1211 Env env{*this, features};
1212
1213 Account const borrower{"borrower"}; // the target account, proposing for itself
1214 Account const bob{"bob"}; // an arbitrary sponsor placeholder
1215
1216 env.fund(XRP(10000), borrower, bob);
1217 env.close();
1218
1219 std::uint32_t const expiration = proposal::expiration(env, 100s);
1220
1221 // LoanSet: the Counterparty's signature is collected later; it must
1222 // not be required up front.
1223 {
1224 std::uint32_t const ticketSeq = proposal::createTicket(env, borrower);
1225
1226 json::Value const tx =
1227 proposal::unsignedPayload(env, loan::set(borrower, UInt256{1}, 1'000), ticketSeq);
1228
1229 env(proposal::create(borrower, tx, expiration), proposal::verify::create());
1230 env.close();
1231 }
1232
1233 // SponsorshipTransfer (account-level reserve sponsorship): the
1234 // Sponsor's signature is likewise collected later.
1235 {
1236 std::uint32_t const ticketSeq = proposal::createTicket(env, borrower);
1237
1238 json::Value tx = sponsor::transfer(borrower, tfSponsorshipCreate);
1239 tx[sfSponsor.getJsonName()] = bob.human();
1240 tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve;
1241
1242 env(proposal::create(
1243 borrower, proposal::unsignedPayload(env, tx, ticketSeq), expiration),
1245 env.close();
1246 }
1247 }
1248
1249 // The proposer holds the proposal's reserve until it is resolved.
1250 void
1252 {
1253 testcase("proposer reserve");
1254
1255 using namespace jtx;
1256 using namespace std::chrono_literals;
1257
1258 Env env{*this, features};
1259
1260 Account const alice{"alice"};
1261 Account const target{"target"};
1262 Account const bob{"bob"};
1263 env.fund(XRP(10000), target, bob);
1264 env.close();
1265 proposal::authorizeProposer(env, target, alice);
1266
1267 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1268
1269 // Fund alice just short of the reserve the proposal requires.
1270 env.fund(
1271 env.current()->fees().accountReserve(proposal::kProposalOwnerCount, 1) - drops(1),
1272 alice);
1273 env.close();
1274
1275 std::uint32_t const expiration = proposal::expiration(env, 100s);
1276 json::Value const proposedTx =
1277 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1278
1279 env(proposal::create(alice, proposedTx, expiration),
1282 env.close();
1283
1284 env(pay(bob, alice, XRP(10)));
1285 env.close();
1286
1287 env(proposal::create(alice, proposedTx, expiration), proposal::verify::create());
1288 env.close();
1289 }
1290
1291 // The proposal's reserve can instead be sponsored: the reserve is charged
1292 // to the sponsor's account, and the ledger object records the sponsor, the
1293 // same as any other reserve-sponsorable object (TransactionProposalCreate
1294 // is on the reserve-sponsorship allow-list).
1295 void
1297 {
1298 testcase("proposer reserve sponsored");
1299
1300 using namespace jtx;
1301 using namespace std::chrono_literals;
1302
1303 // Reserve sponsorship requires the Sponsor amendment, independent of
1304 // Cosign: with Cosign enabled but Sponsor disabled, a proposal that
1305 // tries to attach a sponsor is rejected before it ever reaches the
1306 // reserve-sponsorship allow-list.
1307 {
1308 Env env{*this, features - featureSponsor};
1309
1310 Account const alice{"alice"};
1311 Account const target{"target"};
1312 Account const bob{"bob"};
1313 Account const backer{"backer"};
1314 env.fund(XRP(10000), alice, target, bob, backer);
1315 env.close();
1316 proposal::authorizeProposer(env, target, alice);
1317
1318 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1319 json::Value const proposedTx =
1320 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1321
1322 env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)),
1324 Sig(sfSponsorSignature, backer),
1327 env.close();
1328 BEAST_EXPECT(!proposal::entry(env, target, targetTicketSeq));
1329 }
1330
1331 Env env{*this, features};
1332
1333 Account const alice{"alice"}; // the proposer
1334 Account const target{"target"}; // the account the proposal is for
1335 Account const bob{"bob"};
1336 Account const backer{"backer"}; // sponsors alice's proposal reserve
1337
1338 env.fund(XRP(10000), alice, target, bob, backer);
1339 env.close();
1340 proposal::authorizeProposer(env, target, alice);
1341
1342 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1343 json::Value const proposedTx =
1344 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1345
1346 env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)),
1348 Sig(sfSponsorSignature, backer),
1350 env.close();
1351
1352 auto const sle = proposal::entry(env, target, targetTicketSeq);
1353 if (!BEAST_EXPECT(sle))
1354 return;
1355
1356 BEAST_EXPECT(sle->isFieldPresent(sfSponsor));
1357 BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer.id());
1358
1359 // alice still owns the proposal — her OwnerCount reflects that, same
1360 // as an unsponsored proposal. What moves to the sponsor is the
1361 // reserve requirement itself, tracked separately: alice's owner count
1362 // is covered by backer's sponsorship rather than her own balance.
1363 BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount);
1364 BEAST_EXPECT(ownerCount(env, backer) == 0);
1365 BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount);
1366 BEAST_EXPECT(sponsoringOwnerCount(env, backer) == proposal::kProposalOwnerCount);
1367 }
1368
1369 // A proposal's sponsored reserve can be reassigned to a new sponsor
1370 // through SponsorshipTransfer, the same as any other reserve-sponsored
1371 // ledger entry.
1372 void
1374 {
1375 testcase("proposer reserve sponsorship transferred");
1376
1377 using namespace jtx;
1378 using namespace std::chrono_literals;
1379
1380 Env env{*this, features};
1381
1382 Account const alice{"alice"}; // the proposer
1383 Account const target{"target"}; // the account the proposal is for
1384 Account const bob{"bob"};
1385 Account const backer1{"backer1"}; // the original sponsor
1386 Account const backer2{"backer2"}; // the new sponsor
1387
1388 env.fund(XRP(10000), alice, target, bob, backer1, backer2);
1389 env.close();
1390 proposal::authorizeProposer(env, target, alice);
1391
1392 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1393 json::Value const proposedTx =
1394 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1395
1396 env(proposal::create(alice, proposedTx, proposal::expiration(env, 100s)),
1398 Sig(sfSponsorSignature, backer1),
1400 env.close();
1401
1402 BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == proposal::kProposalOwnerCount);
1403 BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == 0);
1404
1405 Keylet const proposalKeylet = keylet::txProposal(target.id(), targetTicketSeq);
1406
1407 env(sponsor::transfer(alice, tfSponsorshipReassign, proposalKeylet.key),
1409 Sig(sfSponsorSignature, backer2));
1410 env.close();
1411
1412 auto const sle = proposal::entry(env, target, targetTicketSeq);
1413 if (!BEAST_EXPECT(sle))
1414 return;
1415
1416 BEAST_EXPECT(sle->isFieldPresent(sfSponsor));
1417 BEAST_EXPECT(sle->getAccountID(sfSponsor) == backer2.id());
1418
1419 // alice's own OwnerCount is unaffected by the reassignment: only the
1420 // sponsor of the redirected reserve changes.
1421 BEAST_EXPECT(ownerCount(env, alice) == proposal::kProposalOwnerCount);
1422 BEAST_EXPECT(sponsoredOwnerCount(env, alice) == proposal::kProposalOwnerCount);
1423 BEAST_EXPECT(sponsoringOwnerCount(env, backer1) == 0);
1424 BEAST_EXPECT(sponsoringOwnerCount(env, backer2) == proposal::kProposalOwnerCount);
1425 }
1426
1427 // TransactionProposalCreate's own transaction fee can be sponsored like
1428 // any other transaction's, independent of whether its reserve is
1429 // sponsored (On-Chain Cosigner spec sponsorship is orthogonal to fee
1430 // sponsorship).
1431 void
1433 {
1434 testcase("proposal creation fee sponsored");
1435
1436 using namespace jtx;
1437 using namespace std::chrono_literals;
1438
1439 Env env{*this, features};
1440
1441 Account const target{"target"}; // the proposer, proposing for itself
1442 Account const bob{"bob"};
1443 Account const backer{"backer"}; // sponsors target's transaction fee
1444
1445 env.fund(XRP(10000), target, bob, backer);
1446 env.close();
1447
1448 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1449 json::Value const proposedTx =
1450 proposal::unsignedPayload(env, pay(target, bob, XRP(1)), targetTicketSeq);
1451
1452 auto const targetBalance = env.balance(target);
1453 auto const backerBalance = env.balance(backer);
1454 // A generous fixed fee: the exact amount isn't the point of this
1455 // test, only that the sponsor pays it instead of the proposer, so it
1456 // should comfortably clear the minimum even under local fee escalation
1457 // rather than assume the reference fee is some specific small value.
1458 STAmount const feeAmt = XRP(1);
1459
1460 env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)),
1461 Fee(feeAmt),
1462 sponsor::As(backer, spfSponsorFee),
1463 Sig(sfSponsorSignature, backer),
1465 env.close();
1466
1467 BEAST_EXPECT(proposal::entry(env, target, targetTicketSeq));
1468 BEAST_EXPECT(env.balance(target) == targetBalance);
1469 BEAST_EXPECT(env.balance(backer) == backerBalance - feeAmt);
1470 }
1471
1472 // A proposed Batch holds several inner transactions and the signatures of
1473 // every account they touch, so it reserves more than an ordinary proposal.
1474 void
1476 {
1477 testcase("proposed batch reserve");
1478
1479 using namespace jtx;
1480 using namespace std::chrono_literals;
1481
1482 Env env{*this, features};
1483
1484 Account const target{"target"};
1485 Account const bob{"bob"};
1486 env.fund(XRP(10000), target, bob);
1487 env.close();
1488
1489 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1490
1491 // Both inner transactions are the outer account's own, so no further
1492 // signatures will be collected for them.
1493 json::Value const proposedTx = proposal::unsignedBatch(
1494 env,
1495 target,
1496 targetTicketSeq,
1497 tfAllOrNothing,
1498 {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)),
1499 proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target) + 1)});
1500
1501 env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)),
1503 env.close();
1504
1505 // target owns its own Ticket plus the batch proposal.
1506 BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount);
1507 }
1508
1509 // A multi-account Batch is the primary motivating case (On-Chain Cosigner spec §10): its inner
1510 // transactions touch accounts other than the outer one, so submitting it
1511 // directly would require a BatchSigners entry per participant. A proposal is
1512 // stored unsigned, so those signatures are collected on-ledger afterward and
1513 // the signer-presence match is skipped at creation time (On-Chain Cosigner spec §5.3.1.2).
1514 void
1516 {
1517 testcase("proposed multi-account batch");
1518
1519 using namespace jtx;
1520 using namespace std::chrono_literals;
1521
1522 Env env{*this, features};
1523
1524 Account const target{"target"}; // outer account of the batch, proposing for itself
1525 Account const bob{"bob"}; // a distinct inner participant
1526 env.fund(XRP(10000), target, bob);
1527 env.close();
1528
1529 std::uint32_t const targetTicketSeq = proposal::createTicket(env, target);
1530
1531 // One inner from the outer account, one from bob: bob is a required
1532 // signer, so a direct submission would need his BatchSigners entry.
1533 json::Value const proposedTx = proposal::unsignedBatch(
1534 env,
1535 target,
1536 targetTicketSeq,
1537 tfAllOrNothing,
1538 {proposal::innerTx(pay(target, bob, XRP(1)), env.seq(target)),
1539 proposal::innerTx(pay(bob, target, XRP(1)), env.seq(bob))});
1540
1541 env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)),
1543 env.close();
1544
1545 auto const sle = proposal::entry(env, target, targetTicketSeq);
1546 if (!BEAST_EXPECT(sle))
1547 return;
1548
1549 // The proposal is stored without any BatchSigners: the participants'
1550 // signatures are collected later through TransactionProposalSign.
1551 auto const stored = sle->getFieldObject(sfProposedTransaction);
1552 BEAST_EXPECT(!stored.isFieldPresent(sfBatchSigners));
1553 // target owns its own Ticket plus the batch proposal.
1554 BEAST_EXPECT(ownerCount(env, target) == 1 + proposal::kBatchProposalOwnerCount);
1555 }
1556
1557 // A proposed Batch's inner preflight must receive TapProposal, or an
1558 // unsigned account-reserve SponsorshipTransfer is rejected at Create
1559 // (On-Chain Cosigner spec §6.1.1: an inner Sponsor is a collectable
1560 // signature slot). Other inner types that do not key on TapProposal keep
1561 // their existing preflight result.
1562 void
1564 {
1565 testcase("proposed batch inner account-reserve SponsorshipTransfer");
1566
1567 using namespace jtx;
1568 using namespace std::chrono_literals;
1569
1570 Env env{*this, features};
1571
1572 Account const target{"target"};
1573 Account const bob{"bob"}; // named as Sponsor; signature collected later
1574 env.fund(XRP(10000), target, bob);
1575 env.close();
1576
1577 auto unsignedInnerSponsorship = [&](Account const& account) {
1578 json::Value tx = sponsor::transfer(account, tfSponsorshipCreate);
1579 tx[sfSponsor.getJsonName()] = bob.human();
1580 tx[sfSponsorFlags.getJsonName()] = spfSponsorReserve;
1581 return tx;
1582 };
1583
1584 // Payment (unaffected by TapProposal) plus an unsigned inner
1585 // account-reserve SponsorshipTransfer. Create succeeds only if
1586 // TapProposal reaches the inner.
1587 {
1588 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
1589 auto const seq = env.seq(target);
1590 json::Value const proposedTx = proposal::unsignedBatch(
1591 env,
1592 target,
1593 ticketSeq,
1594 tfAllOrNothing,
1595 {proposal::innerTx(pay(target, bob, XRP(1)), seq),
1596 proposal::innerTx(unsignedInnerSponsorship(target), seq + 1)});
1597
1598 env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)),
1600 env.close();
1601 BEAST_EXPECT(proposal::entry(env, target, ticketSeq));
1602 }
1603
1604 // Structural inner failures are unchanged: missing sfSponsor is still
1605 // temMALFORMED in SponsorshipTransfer::preflight, collapsed by Batch
1606 // to temINVALID_INNER_BATCH. TapProposal does not skip that.
1607 {
1608 std::uint32_t const ticketSeq = proposal::createTicket(env, target);
1609 auto const seq = env.seq(target);
1610 json::Value const proposedTx = proposal::unsignedBatch(
1611 env,
1612 target,
1613 ticketSeq,
1614 tfAllOrNothing,
1615 {proposal::innerTx(pay(target, bob, XRP(1)), seq),
1616 proposal::innerTx(sponsor::transfer(target, tfSponsorshipCreate), seq + 1)});
1617
1618 env(proposal::create(target, proposedTx, proposal::expiration(env, 100s)),
1621 env.close();
1622 BEAST_EXPECT(!proposal::entry(env, target, ticketSeq));
1623 }
1624 }
1625
1626 void
1627 run() override
1628 {
1629 using namespace jtx;
1630
1631 FeatureBitset const all{testableAmendments()};
1632
1634
1635 // Preflight
1636 testDisabled(all);
1639
1640 // Preclaim
1641 testPreclaim(all);
1647 testPseudoTarget(all);
1648
1649 // Apply
1650 testCreate(all);
1653 testReserve(all);
1656 testFeeSponsored(all);
1657 testBatchReserve(all);
1660 }
1661};
1662
1664
1665} // namespace xrpl::test
T at(T... args)
A generic endpoint for log messages.
Definition Journal.h:44
A testsuite class.
Definition suite.h:52
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
Represents a JSON value.
Definition json_value.h:117
Value removeMember(char const *key)
Remove and return the named member.
bool modify(ModifyType const &f)
Modify the open ledger.
Writable ledger view that accumulates state and tx changes.
Definition OpenView.h:59
void rawReplace(SLE::Ref sle) override
Unconditionally replace a state item.
Definition OpenView.cpp:244
Slice slice() const noexcept
Definition PublicKey.h:115
static constexpr SeqProxy rawTicket(std::uint32_t v)
Factory function to return a ticket-based SeqProxy.
Definition SeqProxy.h:74
virtual OpenLedger & getOpenLedger()=0
Convenience class to test AMM functionality.
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
std::string const & human() const
Returns the human readable public key.
PublicKey const & pk() const
Return the public key.
Definition jtx/Account.h:84
AccountID id() const
Returns the Account ID.
A transaction testing environment.
Definition Env.h:161
Application & app()
Definition Env.h:300
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
SLE::const_pointer le(Account const &account) const
Return an account root.
Definition Env.cpp:311
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
std::uint32_t seq(Account const &account) const
Returns the next sequence number on account.
Definition Env.cpp:302
PrettyAmount balance(Account const &account) const
Returns the XRP balance on an account.
Definition Env.cpp:201
void trust(STAmount const &amount, Account const &account)
Establish trust lines.
Definition Env.cpp:354
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:377
Set the fee on a JTx.
Definition fee.h:20
Set the regular signature on a JTx.
Definition sig.h:19
Set the expected result code for a JTx The test will fail if the code doesn't match.
Definition ter.h:18
Set a ticket sequence on a JTx.
Definition ticket.h:36
@ Object
object value (collection of name/value pairs).
Definition json_value.h:29
Keylet signerList(AccountID const &account) noexcept
A SignerList.
Definition Indexes.cpp:348
Keylet txProposal(AccountID const &target, std::uint32_t ticketSequence) noexcept
A TransactionProposal.
Definition Indexes.cpp:366
Keylet ticket(AccountID const &id, SeqProxy const &ticketSeq)
A ticket belonging to an account.
Definition Indexes.cpp:332
json::Value set(jtx::Account const &account, jtx::Account const &authorize, std::vector< std::string > const &permissions)
Definition delegate.cpp:17
json::Value auth(Account const &account, Account const &auth)
Preauthorize for deposit.
Definition deposit.cpp:16
json::Value set(AccountID const &account, UInt256 const &loanBrokerID, Number principalRequested, std::uint32_t flags)
Create create()
Check the ledger effects a TransactionProposalCreate must have, whatever its outcome: on tesSUCCESS a...
Definition proposal.h:76
json::Value innerTx(json::Value tx, std::uint32_t seq)
Put a transaction into the form an inner transaction of a proposed Batch takes, as batch::Inner does ...
Definition proposal.cpp:70
std::uint32_t createTicket(Env &env, Account const &account, std::uint32_t count)
Create tickets for a proposal to be built against, and close the ledger.
Definition proposal.cpp:118
json::Value unsignedPayload(Env const &env, json::Value tx, std::uint32_t ticketSeq)
Put a transaction of any type into the form a proposal stores it in: unsigned and ticket-based,...
Definition proposal.cpp:51
constexpr std::uint32_t kBatchProposalOwnerCount
Owner-reserve increments held by a proposal of a Batch transaction.
constexpr std::uint32_t kProposalOwnerCount
Owner-reserve increments held by a proposal of an ordinary transaction.
SLE::const_pointer entry(Env const &env, AccountID const &target, std::uint32_t ticketSeq)
The proposal stored against a target account's ticket.
Definition proposal.cpp:135
json::Value create(Account const &proposer, json::Value const &proposedTx, std::uint32_t expiration)
Build a TransactionProposalCreate carrying an unsigned proposed transaction.
Definition proposal.cpp:40
void authorizeProposer(Env &env, Account const &target, Account const &proposer)
Give proposer a place on target's SignerList, so it may create proposals against target.
Definition proposal.cpp:111
json::Value unsignedBatch(Env const &env, Account const &target, std::uint32_t ticketSeq, std::uint32_t flags, std::vector< json::Value > const &inners, std::optional< std::uint32_t > numSigners)
An unsigned outer Batch payload holding inners.
Definition proposal.cpp:76
std::uint32_t expiration(Env &env, NetClock::duration delta)
An absolute expiration delta past the environment's current time.
Definition proposal.cpp:129
json::Value transfer(jtx::Account const &account, uint32_t flags, std::optional< UInt256 > const &index)
Definition sponsor.cpp:52
json::Value mint(jtx::Account const &account, std::uint32_t nfTokenTaxon)
Mint an NFToken.
Definition token.cpp:23
json::Value pay(AccountID const &account, AccountID const &to, AnyAmount amount)
Create a payment.
Definition pay.cpp:14
XrpT const XRP
Converts to XRP Issue or STAmount.
Definition amount.cpp:92
json::Value noop(Account const &account)
The null transaction.
Definition noop.h:14
std::uint32_t ownerCount(Env const &env, Account const &account)
std::uint32_t sponsoringOwnerCount(Env const &env, Account const &account)
FeatureBitset testableAmendments()
Definition Env.h:92
json::Value offer(Account const &account, STAmount const &takerPays, STAmount const &takerGets, std::uint32_t flags)
Create an offer.
Definition offer.cpp:14
std::uint32_t sponsoredOwnerCount(Env const &env, Account const &account)
json::Value trust(Account const &account, STAmount const &amount, std::uint32_t flags)
Modify a trust line.
Definition trust.cpp:18
PrettyAmount drops(Integer i)
Returns an XRP PrettyAmount, which is trivially convertible to STAmount.
json::Value signers(Account const &account, std::uint32_t quorum, std::vector< Signer > const &v)
Definition multisign.cpp:31
json::Value fset(Account const &account, std::uint32_t on, std::uint32_t off=0)
Add and/or remove flag.
Definition flags.cpp:15
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
constexpr FlagValue spfSponsorFee
Definition TxFlags.h:465
constexpr FlagValue tfInnerBatchTxn
Definition TxFlags.h:44
ApplyResult apply(ServiceRegistry &registry, OpenView &view, STTx const &tx, ApplyFlags flags, beast::Journal journal)
Apply a transaction to an OpenView.
Definition apply.cpp:181
std::string strHex(FwdIt begin, FwdIt end)
Definition strHex.h:13
@ tefBAD_LEDGER
Definition TER.h:165
@ tefNO_TICKET
Definition TER.h:180
std::string toBase58(AccountID const &v)
Convert AccountID to base58 checked string.
Definition AccountID.cpp:95
BaseUInt< 256 > UInt256
Definition base_uint.h:580
@ temBAD_EXPIRATION
Definition TER.h:79
@ temINVALID
Definition TER.h:98
@ temMALFORMED
Definition TER.h:75
@ temSEQ_AND_TICKET
Definition TER.h:114
@ temDISABLED
Definition TER.h:102
@ temBAD_AMOUNT
Definition TER.h:77
@ temINVALID_INNER_BATCH
Definition TER.h:131
@ temBAD_SIGNER
Definition TER.h:103
TERSubset< CanCvtToTER > TER
Definition TER.h:654
@ tecNO_TARGET
Definition TER.h:312
@ tecEXPIRED
Definition TER.h:322
@ tecINSUFFICIENT_RESERVE
Definition TER.h:315
@ tecNO_PERMISSION
Definition TER.h:313
@ tecDUPLICATE
Definition TER.h:323
constexpr FlagValue spfSponsorReserve
Definition TxFlags.h:466
@ tesSUCCESS
Definition TER.h:250
T size(T... args)
A pair of SHAMap key and LedgerEntryType.
Definition Keylet.h:20
UInt256 key
Definition Keylet.h:21