xrpld
Loading...
Searching...
No Matches
InvariantsMPT_test.cpp
1#include <test/app/invariants/InvariantsBase.h>
2#include <test/jtx/Account.h>
3#include <test/jtx/Env.h>
4#include <test/jtx/amount.h>
5#include <test/jtx/mpt.h>
6#include <test/jtx/pay.h>
7#include <test/jtx/trust.h>
8#include <test/jtx/vault.h>
9
10#include <xrpl/basics/base_uint.h>
11#include <xrpl/beast/unit_test/suite.h>
12#include <xrpl/ledger/ApplyView.h>
13#include <xrpl/protocol/AccountID.h>
14#include <xrpl/protocol/Feature.h>
15#include <xrpl/protocol/Indexes.h>
16#include <xrpl/protocol/Issue.h>
17#include <xrpl/protocol/LedgerFormats.h>
18#include <xrpl/protocol/MPTIssue.h>
19#include <xrpl/protocol/Protocol.h>
20#include <xrpl/protocol/SField.h>
21#include <xrpl/protocol/STAmount.h>
22#include <xrpl/protocol/STLedgerEntry.h>
23#include <xrpl/protocol/STObject.h>
24#include <xrpl/protocol/STTx.h>
25#include <xrpl/protocol/SeqProxy.h>
26#include <xrpl/protocol/TER.h>
27#include <xrpl/protocol/TxFlags.h>
28#include <xrpl/protocol/TxFormats.h>
29#include <xrpl/protocol/UintTypes.h>
30#include <xrpl/protocol/XRPAmount.h>
31#include <xrpl/tx/ApplyContext.h>
32#include <xrpl/tx/applySteps.h>
33
34#include <array>
35#include <cstdint>
36#include <functional>
37#include <initializer_list>
38#include <memory>
39#include <ranges>
40#include <source_location>
41#include <string>
42#include <tuple>
43#include <utility>
44#include <vector>
45
46namespace xrpl::test {
47
49{
51
52 void
54 {
55 using namespace test::jtx;
56 testcase << "MPT";
57
58 MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))};
59 auto const nonCanonicalMPTAmount = [&](SField const& field) {
60 return STAmount{
61 field,
62 nonCanonicalMPTIssue,
64 0,
65 false,
67 };
68 auto const negativeMPTAmount = [&](SField const& field) {
69 return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}};
70 };
71 auto const nonCanonicalMPTPayment = [&]() {
72 return STTx{ttPAYMENT, [&](STObject& tx) {
73 tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount));
74 }};
75 };
76
78 makeEnv(all_ - fixCleanup3_2_0),
79 {},
80 [](Account const&, Account const&, ApplyContext&) { return true; },
81 XRPAmount{},
82 nonCanonicalMPTPayment(),
84
86 {{"ledger entry contains non-canonical MPT or XRP amount"}},
87 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
88 auto const sle = ac.view().peek(keylet::account(a1.id()));
89 if (!sle)
90 return false;
91
92 auto sleNew = std::make_shared<SLE>(
93 keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
94 sleNew->setAccountID(sfAccount, a1.id());
95 sleNew->setAccountID(sfDestination, a2.id());
96 sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax));
97 ac.view().insert(sleNew);
98 return true;
99 });
100
102 {{"ledger entry contains non-canonical MPT or XRP amount"}},
103 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
104 auto const sle = ac.view().peek(keylet::account(a1.id()));
105 if (!sle)
106 return false;
107
108 auto sleNew = std::make_shared<SLE>(
109 keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence])));
110 sleNew->setAccountID(sfAccount, a1.id());
111 sleNew->setAccountID(sfDestination, a2.id());
112 sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax));
113 ac.view().insert(sleNew);
114 return true;
115 });
116
117 // MPT OutstandingAmount > MaximumAmount
119 {{"OutstandingAmount overflow"}},
120 [](Account const& a1, Account const&, ApplyContext& ac) {
121 // mptissuance outstanding is negative
122 auto const sle = ac.view().peek(keylet::account(a1.id()));
123 if (!sle)
124 return false;
125
126 MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
128 sleNew->setFieldU64(sfOutstandingAmount, 110);
129 sleNew->setFieldU64(sfMaximumAmount, 100);
130 ac.view().insert(sleNew);
131 return true;
132 });
133
134 // MPTToken amount doesn't add up to OutstandingAmount
136 {{"invalid OutstandingAmount balance"}},
137 [](Account const& a1, Account const& a2, ApplyContext& ac) {
138 // mptissuance outstanding is negative
139 auto const sle = ac.view().peek(keylet::account(a1.id()));
140 if (!sle)
141 return false;
142
143 MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)};
145 sleNew->setFieldU64(sfOutstandingAmount, 100);
146 sleNew->setFieldU64(sfMaximumAmount, 100);
147 ac.view().insert(sleNew);
148
150 sleNew->setFieldU64(sfMPTAmount, 90);
151 ac.view().insert(sleNew);
152
153 return true;
154 });
155
156 // Overflow/Invalid balance on payment
157 auto testPayment = [&](std::string const& log, auto&& update) {
158 MPTID id;
160 {{log}},
161 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
162 return update(id, ac, a1);
163 },
164 XRPAmount{},
165 STTx{ttPAYMENT, [](STObject& tx) {}},
167 [&](Account const& a1, Account const& a2, Env& env) {
168 Account const gw("gw");
169 env.fund(XRP(1'000), gw);
170 MPTTester const mpt(
171 {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100});
172 id = mpt.issuanceID();
173 return true;
174 });
175 };
176 testPayment(
177 "invalid OutstandingAmount balance",
178 [&](MPTID const& id, ApplyContext& ac, Account const& a1) {
179 auto sle = ac.view().peek(keylet::mptoken(id, a1));
180 if (!sle)
181 return false;
182 sle->setFieldU64(sfMPTAmount, 101);
183 ac.view().update(sle);
184 return true;
185 });
186 testPayment(
187 "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) {
188 auto sle = ac.view().peek(keylet::mptokenIssuance(id));
189 if (!sle)
190 return false;
191 sle->setFieldU64(sfOutstandingAmount, 101);
192 ac.view().update(sle);
193 return true;
194 });
195
196 // The on-failure MPT checks (OutstandingAmount balance / transfer) apply
197 // to every non-tesSUCCESS result, with no per-result exemption: on a tec
198 // the transactor discards the view and re-applies only offer, trust
199 // line, NFT offer and credential deletions, so an MPT change reaching
200 // the invariant is a bug whatever the code. Seeded via initialResult.
201 {
202 MPTID id;
203 // preclose: gw issues an MPT held by A1 and A2.
204 auto const setup = [&](Account const& a1, Account const& a2, Env& env) {
205 Account const gw("gw");
206 env.fund(XRP(1'000), gw);
207 MPTTester const mpt(
208 {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000});
209 id = mpt.issuanceID();
210 return true;
211 };
212
213 // Consistent mint: OutstandingAmount and A1's balance both grow by
214 // 10, so conservation holds and only the on-failure check fires.
215 Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) {
216 auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
217 auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
218 if (!sleIss || !sleTok)
219 return false;
220 (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10;
221 (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
222 ac.view().update(sleIss);
223 ac.view().update(sleTok);
224 return true;
225 };
226
227 // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is
228 // unchanged, and CanTransfer keeps the ordinary transfer check
229 // quiet, so only the on-failure check fires.
230 Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) {
231 auto sleIss = ac.view().peek(keylet::mptokenIssuance(id));
232 auto sleA = ac.view().peek(keylet::mptoken(id, a1.id()));
233 auto sleB = ac.view().peek(keylet::mptoken(id, a2.id()));
234 if (!sleIss || !sleA || !sleB)
235 return false;
236 (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer;
237 (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10;
238 (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10;
239 ac.view().update(sleIss);
240 ac.view().update(sleA);
241 ac.view().update(sleB);
242 return true;
243 };
244
245 STTx const payment{ttPAYMENT, [](STObject&) {}};
246
247 // Negative controls: nothing fires on tesSUCCESS. Without these, the
248 // cases below would still pass if the result guard were dropped.
249 doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
250 doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
251
252 // tecKILLED and tecINCOMPLETE are not special: an MPT change paired
253 // with either fires, as with any other failure.
255 {{"OutstandingAmount balance changed on failure"}},
256 mint,
257 XRPAmount{},
258 payment,
260 setup,
263 tecKILLED);
265 {{"OutstandingAmount balance changed on failure"}},
266 mint,
267 XRPAmount{},
268 payment,
270 setup,
275 {{"MPToken balance changed on failure"}},
276 transfer,
277 XRPAmount{},
278 payment,
280 setup,
283 tecKILLED);
285 {{"MPToken balance changed on failure"}},
286 transfer,
287 XRPAmount{},
288 payment,
290 setup,
294 // The same change under a third failure result: the check keys off
295 // "not tesSUCCESS", nothing finer.
297 {{"OutstandingAmount balance changed on failure"}},
298 mint,
299 XRPAmount{},
300 payment,
302 setup,
305 tecEXPIRED);
307 {{"MPToken balance changed on failure"}},
308 transfer,
309 XRPAmount{},
310 payment,
312 setup,
315 tecEXPIRED);
316
317 // A lock moves value within one holder, so it is not a two-sided
318 // transfer and the `senders || receivers` form is what catches it.
319 // OutstandingAmount and the holder total are unchanged, so the
320 // balance check stays quiet.
321 Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) {
322 auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id()));
323 if (!sleTok || (*sleTok)[sfMPTAmount] < 10)
324 return false;
325 // A fresh MPToken has no locked amount, so set it directly.
326 (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10;
327 sleTok->setFieldU64(sfLockedAmount, 10);
328 ac.view().update(sleTok);
329 return true;
330 };
331 // Negative control: a lock is legitimate on tesSUCCESS.
332 doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup);
334 {{"MPToken balance changed on failure"}},
335 lock,
336 XRPAmount{},
337 payment,
339 setup,
342 tecKILLED);
343 // The lock is caught under any failure result.
345 {{"MPToken balance changed on failure"}},
346 lock,
347 XRPAmount{},
348 payment,
350 setup,
353 tecEXPIRED);
354
355 // A deleted MPToken has no amtAfter, so the sender/receiver counts
356 // skip it and only the deletedAuthorized_ term can catch it. That
357 // needs holders authorized but never paid, so the MPToken can be
358 // erased with a zero balance and OutstandingAmount untouched --
359 // otherwise the holder would register as a sender instead.
360 MPTID emptyId;
361 auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) {
362 Account const gw("gw");
363 env.fund(XRP(1'000), gw);
364 MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100});
365 emptyId = mpt.issuanceID();
366 return true;
367 };
368 Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) {
369 auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id()));
370 if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
371 return false;
372 ac.view().erase(sleTok);
373 return true;
374 };
375 // ValidMPTIssuance also reports the deletion, so assert on
376 // ValidMPTTransfer's message, which only the new check can produce.
378 {{"MPToken deleted on failure"}},
379 eraseToken,
380 XRPAmount{},
381 payment,
383 setupEmpty,
386 tecEXPIRED);
387 }
388
389 // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation.
390 {
391 Env env(*this, all_);
392 Account const issuer{"issuer"};
393 Account const holder{"holder"};
394 Account const other{"other"};
395 env.fund(XRP(1'000), issuer, holder, other);
396 auto const usd = issuer["USD"];
397 env.trust(usd(100), holder);
398 env(pay(issuer, holder, usd(100)));
399 env.close();
400
402 std::move(env),
403 holder,
404 other,
405 {{"Invariant failed: trustline clawback balance change is invalid"}},
406 [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
407 auto sle =
408 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
409 if (!sle)
410 return false;
411
412 STAmount balance{Issue{usd.currency, issuer.id()}, 80};
413 if (holder.id() > issuer.id())
414 balance.negate();
415 sle->setFieldAmount(sfBalance, balance);
416 ac.view().update(sle);
417 return true;
418 },
419 XRPAmount{},
420 STTx{
421 ttCLAWBACK,
422 [&](STObject& tx) {
423 tx[sfAccount] = issuer.id();
424 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
425 }},
427 }
428
429 // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance.
430 {
431 Env env(*this, all_);
432 Account const issuer{"issuer"};
433 Account const holder{"holder"};
434 Account const other{"other"};
435 env.fund(XRP(1'000), issuer, holder, other);
436 auto const usd = issuer["USD"];
437 env.trust(usd(100), holder);
438 env(pay(issuer, holder, usd(100)));
439 env.close();
440
442 std::move(env),
443 holder,
444 other,
445 {},
446 [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
447 auto const sle =
448 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
449 if (!sle)
450 return false;
451
452 ac.view().erase(sle);
453 return true;
454 },
455 XRPAmount{},
456 STTx{
457 ttCLAWBACK,
458 [&](STObject& tx) {
459 tx[sfAccount] = issuer.id();
460 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100};
461 }},
463 }
464
465 // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing.
466 {
467 Env env(*this, all_ - featureMPTokensV2);
468 Account const issuer{"issuer"};
469 Account const holder{"holder"};
470 Account const other{"other"};
471 env.fund(XRP(1'000), issuer, holder, other);
472 auto const usd = issuer["USD"];
473 env.trust(usd(100), holder);
474 env(pay(issuer, holder, usd(100)));
475 env.close();
476
478 std::move(env),
479 holder,
480 other,
481 {{"Invariant failed: trustline clawback balance change is invalid"}},
482 [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
483 auto sle =
484 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
485 if (!sle)
486 return false;
487
488 STAmount balance{Issue{usd.currency, issuer.id()}, 80};
489 if (holder.id() > issuer.id())
490 balance.negate();
491 sle->setFieldAmount(sfBalance, balance);
492 ac.view().update(sle);
493 return true;
494 },
495 XRPAmount{},
496 STTx{
497 ttCLAWBACK,
498 [&](STObject& tx) {
499 tx[sfAccount] = issuer.id();
500 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
501 }},
503 }
504
505 // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount.
506 {
507 Env env(*this, all_);
508 Account const issuer{"issuer"};
509 Account const holder{"holder"};
510 Account const other{"other"};
511 env.fund(XRP(1'000), issuer, holder, other);
512 MPTTester const mpt(
513 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
514 auto const id = mpt.issuanceID();
515
517 std::move(env),
518 holder,
519 other,
520 {{"Invariant failed: MPT clawback balance change is invalid"}},
521 [id](Account const& holder, Account const&, ApplyContext& ac) {
522 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
523 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
524 if (!sleToken || !sleIssuance)
525 return false;
526
527 sleToken->setFieldU64(sfMPTAmount, 80);
528 sleIssuance->setFieldU64(sfOutstandingAmount, 80);
529 ac.view().update(sleToken);
530 ac.view().update(sleIssuance);
531 return true;
532 },
533 XRPAmount{},
534 STTx{
535 ttCLAWBACK,
536 [&](STObject& tx) {
537 tx[sfAccount] = issuer.id();
538 tx[sfHolder] = holder.id();
539 tx[sfAmount] = STAmount{MPTIssue{id}, 10};
540 }},
542 }
543
544 // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2.
545 {
546 Env env(*this, all_);
547 Account const issuer{"issuer"};
548 Account const holder{"holder"};
549 Account const other{"other"};
550 env.fund(XRP(1'000), issuer, holder, other);
551 auto const usd = issuer["USD"];
552 env.trust(usd(100), holder);
553 env(pay(issuer, holder, usd(100)));
554 MPTTester const mpt(
555 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
556 auto const id = mpt.issuanceID();
557
559 std::move(env),
560 holder,
561 other,
562 {{"Invariant failed: trustline and MPToken both changed"}},
563 [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) {
564 auto const sleLine =
565 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
566 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id()));
567 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
568 if (!sleLine || !sleToken || !sleIssuance)
569 return false;
570
571 STAmount balance{Issue{usd.currency, issuer.id()}, 90};
572 if (holder.id() > issuer.id())
573 balance.negate();
574 sleLine->setFieldAmount(sfBalance, balance);
575 sleToken->setFieldU64(sfMPTAmount, 90);
576 sleIssuance->setFieldU64(sfOutstandingAmount, 90);
577 ac.view().update(sleLine);
578 ac.view().update(sleToken);
579 ac.view().update(sleIssuance);
580 return true;
581 },
582 XRPAmount{},
583 STTx{
584 ttCLAWBACK,
585 [&](STObject& tx) {
586 tx[sfAccount] = issuer.id();
587 tx[sfHolder] = holder.id();
588 tx[sfAmount] = STAmount{MPTIssue{id}, 10};
589 }},
591 }
592
593 // Clawback that modifies a trustline other than the one implied by the
594 // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for
595 // the mismatched line.
596 {
597 Env env(*this, all_);
598 Account const issuer{"issuer"};
599 Account const holder{"holder"};
600 Account const other{"other"};
601 env.fund(XRP(1'000), issuer, holder, other);
602 auto const usd = issuer["USD"];
603 auto const eur = issuer["EUR"];
604 env.trust(eur(100), holder);
605 env(pay(issuer, holder, eur(100)));
606 env.close();
607
609 std::move(env),
610 holder,
611 other,
612 {{"Invariant failed: trustline clawback changed the wrong line"}},
613 [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) {
614 auto sle =
615 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency));
616 if (!sle)
617 return false;
618 STAmount balance{Issue{eur.currency, issuer.id()}, 90};
619 if (holder.id() > issuer.id())
620 balance.negate();
621 sle->setFieldAmount(sfBalance, balance);
622 ac.view().update(sle);
623 return true;
624 },
625 XRPAmount{},
626 STTx{
627 ttCLAWBACK,
628 [&](STObject& tx) {
629 tx[sfAccount] = issuer.id();
630 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
631 }},
633 }
634
635 // Clawback leaving the holder's balance negative.
636 {
637 Env env(*this, all_);
638 Account const issuer{"issuer"};
639 Account const holder{"holder"};
640 Account const other{"other"};
641 env.fund(XRP(1'000), issuer, holder, other);
642 auto const usd = issuer["USD"];
643 env.trust(usd(100), holder);
644 env(pay(issuer, holder, usd(100)));
645 env.close();
646
648 std::move(env),
649 holder,
650 other,
651 {{"Invariant failed: trustline or MPT balance is negative"}},
652 [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
653 auto sle =
654 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
655 if (!sle)
656 return false;
657 // Make the holder's balance negative from their perspective.
658 STAmount balance{Issue{usd.currency, issuer.id()}, 80};
659 if (holder.id() < issuer.id())
660 balance.negate();
661 sle->setFieldAmount(sfBalance, balance);
662 ac.view().update(sle);
663 return true;
664 },
665 XRPAmount{},
666 STTx{
667 ttCLAWBACK,
668 [&](STObject& tx) {
669 tx[sfAccount] = issuer.id();
670 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
671 }},
673 }
674
675 // IOU-amount clawback while only an MPToken changed: no trustline was
676 // recorded, so iou_.before is empty.
677 {
678 Env env(*this, all_);
679 Account const issuer{"issuer"};
680 Account const holder{"holder"};
681 Account const other{"other"};
682 env.fund(XRP(1'000), issuer, holder, other);
683 auto const usd = issuer["USD"];
684 MPTTester const mpt(
685 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
686 auto const id = mpt.issuanceID();
687
689 std::move(env),
690 holder,
691 other,
692 {{"Invariant failed: trustline clawback changed the wrong line"}},
693 [id](Account const& holder, Account const&, ApplyContext& ac) {
694 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
695 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
696 if (!sleToken || !sleIssuance)
697 return false;
698 sleToken->setFieldU64(sfMPTAmount, 90);
699 sleIssuance->setFieldU64(sfOutstandingAmount, 90);
700 ac.view().update(sleToken);
701 ac.view().update(sleIssuance);
702 return true;
703 },
704 XRPAmount{},
705 STTx{
706 ttCLAWBACK,
707 [&](STObject& tx) {
708 tx[sfAccount] = issuer.id();
709 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10};
710 }},
712 }
713
714 // Valid trustline change but a zero clawback amount.
715 {
716 Env env(*this, all_);
717 Account const issuer{"issuer"};
718 Account const holder{"holder"};
719 Account const other{"other"};
720 env.fund(XRP(1'000), issuer, holder, other);
721 auto const usd = issuer["USD"];
722 env.trust(usd(100), holder);
723 env(pay(issuer, holder, usd(100)));
724 env.close();
725
727 std::move(env),
728 holder,
729 other,
730 {{"Invariant failed: trustline clawback amount is invalid"}},
731 [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) {
732 auto sle =
733 ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency));
734 if (!sle)
735 return false;
736 STAmount balance{Issue{usd.currency, issuer.id()}, 90};
737 if (holder.id() > issuer.id())
738 balance.negate();
739 sle->setFieldAmount(sfBalance, balance);
740 ac.view().update(sle);
741 return true;
742 },
743 XRPAmount{},
744 STTx{
745 ttCLAWBACK,
746 [&](STObject& tx) {
747 tx[sfAccount] = issuer.id();
748 tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0};
749 }},
751 }
752
753 // MPT clawback tx missing the Holder field.
754 {
755 Env env(*this, all_);
756 Account const issuer{"issuer"};
757 Account const holder{"holder"};
758 Account const other{"other"};
759 env.fund(XRP(1'000), issuer, holder, other);
760 MPTTester const mpt(
761 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
762 auto const id = mpt.issuanceID();
763
765 std::move(env),
766 holder,
767 other,
768 {{"Invariant failed: MPT clawback missing holder"}},
769 [id](Account const& holder, Account const&, ApplyContext& ac) {
770 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
771 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
772 if (!sleToken || !sleIssuance)
773 return false;
774 sleToken->setFieldU64(sfMPTAmount, 90);
775 sleIssuance->setFieldU64(sfOutstandingAmount, 90);
776 ac.view().update(sleToken);
777 ac.view().update(sleIssuance);
778 return true;
779 },
780 XRPAmount{},
781 STTx{
782 ttCLAWBACK,
783 [&](STObject& tx) {
784 tx[sfAccount] = issuer.id();
785 tx[sfAmount] = STAmount{MPTIssue{id}, 10};
786 }},
788 }
789
790 // MPT clawback where the holder's MPToken was deleted (after is empty).
791 {
792 Env env(*this, all_);
793 Account const issuer{"issuer"};
794 Account const holder{"holder"};
795 Account const other{"other"};
796 env.fund(XRP(1'000), issuer, holder, other);
797 MPTTester const mpt(
798 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
799 auto const id = mpt.issuanceID();
800
802 std::move(env),
803 holder,
804 other,
805 {{"Invariant failed: MPT clawback token is missing"}},
806 [id](Account const& holder, Account const&, ApplyContext& ac) {
807 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
808 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
809 if (!sleToken || !sleIssuance)
810 return false;
811 // Keep the issuance consistent after removing the token.
812 sleIssuance->setFieldU64(sfOutstandingAmount, 0);
813 ac.view().update(sleIssuance);
814 ac.view().erase(sleToken);
815 return true;
816 },
817 XRPAmount{},
818 STTx{
819 ttCLAWBACK,
820 [&](STObject& tx) {
821 tx[sfAccount] = issuer.id();
822 tx[sfHolder] = holder.id();
823 tx[sfAmount] = STAmount{MPTIssue{id}, 10};
824 }},
826 }
827
828 // MPT clawback that changed a different holder's MPToken.
829 {
830 Env env(*this, all_);
831 Account const issuer{"issuer"};
832 Account const holder{"holder"};
833 Account const other{"other"};
834 env.fund(XRP(1'000), issuer, holder, other);
835 MPTTester const mpt(
836 {.env = env,
837 .issuer = issuer,
838 .holders = {holder, other},
839 .pay = 100,
840 .maxAmt = 200});
841 auto const id = mpt.issuanceID();
842
844 std::move(env),
845 holder,
846 other,
847 {{"Invariant failed: MPT clawback changed the wrong token"}},
848 [id](Account const&, Account const& other, ApplyContext& ac) {
849 auto const sleToken = ac.view().peek(keylet::mptoken(id, other));
850 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
851 if (!sleToken || !sleIssuance)
852 return false;
853 sleToken->setFieldU64(sfMPTAmount, 90);
854 sleIssuance->setFieldU64(sfOutstandingAmount, 190);
855 ac.view().update(sleToken);
856 ac.view().update(sleIssuance);
857 return true;
858 },
859 XRPAmount{},
860 STTx{
861 ttCLAWBACK,
862 [&](STObject& tx) {
863 tx[sfAccount] = issuer.id();
864 tx[sfHolder] = holder.id();
865 tx[sfAmount] = STAmount{MPTIssue{id}, 10};
866 }},
868 }
869
870 // Valid MPToken change but a zero MPT clawback amount.
871 {
872 Env env(*this, all_);
873 Account const issuer{"issuer"};
874 Account const holder{"holder"};
875 Account const other{"other"};
876 env.fund(XRP(1'000), issuer, holder, other);
877 MPTTester const mpt(
878 {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100});
879 auto const id = mpt.issuanceID();
880
882 std::move(env),
883 holder,
884 other,
885 {{"Invariant failed: MPT clawback amount is invalid"}},
886 [id](Account const& holder, Account const&, ApplyContext& ac) {
887 auto const sleToken = ac.view().peek(keylet::mptoken(id, holder));
888 auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
889 if (!sleToken || !sleIssuance)
890 return false;
891 sleToken->setFieldU64(sfMPTAmount, 90);
892 sleIssuance->setFieldU64(sfOutstandingAmount, 90);
893 ac.view().update(sleToken);
894 ac.view().update(sleIssuance);
895 return true;
896 },
897 XRPAmount{},
898 STTx{
899 ttCLAWBACK,
900 [&](STObject& tx) {
901 tx[sfAccount] = issuer.id();
902 tx[sfHolder] = holder.id();
903 tx[sfAmount] = STAmount{MPTIssue{id}, 0};
904 }},
906 }
907
908 // More MPTokens created than expected
910 std::make_pair(ttAMM_WITHDRAW, 2),
911 std::make_pair(ttAMM_CLAWBACK, 2),
912 std::make_pair(ttAMM_CREATE, 3),
913 std::make_pair(ttCHECK_CASH, 2)};
914 for (auto const& [tx, nTokens] : tests)
915 {
917 {{std::string("MPToken created for the MPT issuer")}},
918 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
919 auto const sle = ac.view().peek(keylet::account(a1.id()));
920 if (!sle)
921 return false;
922
923 auto seq = sle->getFieldU32(sfSequence);
924 for (int i = 0; i < nTokens; ++i)
925 {
926 MPTIssue const mpt{makeMptID(seq + i, a1)};
927 auto sleNew =
929 ac.view().insert(sleNew);
930
932 ac.view().insert(sleNew);
933 }
934
935 return true;
936 },
937 XRPAmount{},
938 STTx{tx, [](STObject& tx) {}},
940 }
941
942 // More MPTokens deleted than expected
943 for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK})
944 {
945 MPTID id;
946 Account const a3("A3");
948 {{"MPT authorize succeeded but created/deleted bad number of mptokens"}},
949 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
950 for (auto const& a : {a1, a2, a3})
951 {
952 auto sle = ac.view().peek(keylet::mptoken(id, a));
953 if (!sle)
954 return false;
955 ac.view().erase(sle);
956 }
957 return true;
958 },
959 XRPAmount{},
960 STTx{tx, [](STObject& tx) {}},
962 [&](Account const& a1, Account const& a2, Env& env) {
963 Account const gw("gw");
964 env.fund(XRP(1'000), gw, a3);
965 MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}});
966 id = mpt.issuanceID();
967 return true;
968 });
969 }
970
971 // LoanSet / VaultWithdraw MayAuthorizeMpt caps (fixCleanup3_4_0):
972 // LoanSet allows at most two creates and no deletes; VaultWithdraw
973 // allows at most one of each. Fabricate one extra mutation so a
974 // too-loose cap would miss these.
975 {
976 auto const insertHolderTokens =
977 [](Account const& issuer, Account const& holder, ApplyContext& ac, int n) {
978 auto const sle = ac.view().peek(keylet::account(issuer.id()));
979 if (!sle)
980 return false;
981 auto seq = sle->getFieldU32(sfSequence);
982 for (int i = 0; i < n; ++i)
983 {
984 MPTIssue const mpt{makeMptID(seq + i, issuer)};
985 auto sleNew =
987 (*sleNew)[sfAccount] = holder.id();
988 (*sleNew)[sfMPTokenIssuanceID] = mpt.getMptID();
989 ac.view().insert(sleNew);
990 }
991 return true;
992 };
993
995 {{ttLOAN_SET, 3}, {ttVAULT_WITHDRAW, 2}}};
996 for (auto const& [txnType, nTokens] : createOverCap)
997 {
999 {{"MPT authorize succeeded but created/deleted bad number mptokens"}},
1000 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
1001 return insertHolderTokens(a1, a2, ac, nTokens);
1002 },
1003 XRPAmount{},
1004 STTx{txnType, [](STObject&) {}},
1006 }
1007
1008 MPTID id;
1009 auto const precloseTwoHolders = [&id](Account const& a1, Account const& a2, Env& env) {
1010 Account const gw("gw");
1011 env.fund(XRP(1'000), gw);
1012 MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}});
1013 id = mpt.issuanceID();
1014 return true;
1015 };
1017 {{ttLOAN_SET, 1}, {ttVAULT_WITHDRAW, 2}}};
1018 for (auto const& [txnType, nTokens] : deleteOverCap)
1019 {
1021 {{"MPT authorize succeeded but created/deleted bad number mptokens"}},
1022 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
1023 std::array const holders{a1, a2};
1024 for (int i = 0; i < nTokens; ++i)
1025 {
1026 auto sle = ac.view().peek(keylet::mptoken(id, holders[i]));
1027 if (!sle)
1028 return false;
1029 ac.view().erase(sle);
1030 }
1031 return true;
1032 },
1033 XRPAmount{},
1034 STTx{txnType, [](STObject&) {}},
1036 precloseTwoHolders);
1037 }
1038 }
1039
1040 // sfReferenceHolding can only be set on creation by VaultCreate. A
1041 // non-VaultCreate transaction that creates an MPTokenIssuance with
1042 // sfReferenceHolding present must trip the invariant.
1044 {{"sfReferenceHolding set on a new MPTokenIssuance by a "
1045 "non-VaultCreate transaction"}},
1046 [](Account const& a1, Account const&, ApplyContext& ac) {
1047 auto const sleAcct = ac.view().peek(keylet::account(a1.id()));
1048 if (!sleAcct)
1049 return false;
1050 MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)};
1052 sleNew->setFieldH256(sfReferenceHolding, UInt256{1});
1053 ac.view().insert(sleNew);
1054 return true;
1055 },
1056 XRPAmount{},
1057 STTx{ttACCOUNT_SET, [](STObject&) {}});
1058
1059 // sfReferenceHolding is immutable: changing the field on an
1060 // existing MPTokenIssuance must trip the invariant. Set up a real
1061 // vault via preclose (so the share issuance carries
1062 // sfReferenceHolding), then mutate it in precheck to produce a
1063 // before/after pair.
1064 {
1065 UInt256 vaultKey;
1067 {{"sfReferenceHolding was modified on an existing "
1068 "MPTokenIssuance"}},
1069 [&](Account const&, Account const&, ApplyContext& ac) {
1070 auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
1071 if (!sleVault)
1072 return false;
1073 auto sleIssuance =
1074 ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
1075 if (!sleIssuance)
1076 return false;
1077 sleIssuance->setFieldH256(sfReferenceHolding, UInt256{2});
1078 ac.view().update(sleIssuance);
1079 return true;
1080 },
1081 XRPAmount{},
1082 STTx{ttACCOUNT_SET, [](STObject&) {}},
1084 [&](Account const& a1, Account const&, Env& env) {
1085 Account const issuer{"issuer"};
1086 env.fund(XRP(10'000), issuer);
1087 env.close();
1088 MPTTester mptt{env, issuer, kMptInitNoFund};
1089 mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
1090 PrettyAsset const asset = mptt.issuanceID();
1091 mptt.authorize({.account = a1});
1092 env.close();
1093
1094 Vault const vault{env};
1095 auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
1096 env(tx);
1097 env.close();
1098 vaultKey = keylet.key;
1099 return true;
1100 });
1101 }
1102
1103 // lsfMPTLocked is exempt because tfMPTUnlock clears it legitimately.
1104 // Pre-fixCleanup3_5_0: clearing another issuance flag is allowed.
1105 // Post-fixCleanup3_5_0: clearing another issuance flag trips the
1106 // invariant.
1107 {
1108 std::uint32_t allFlags = 0;
1109 for (auto const flag : std::views::values(getMPTokenIssuanceFlags()))
1110 allFlags |= flag;
1111 allFlags &= ~lsfMPTLocked;
1112
1113 MPTID id{};
1114 Preclose const setup = [&](Account const&, Account const&, Env& env) {
1115 Account const issuer{"issuer"};
1116 env.fund(XRP(10'000), issuer);
1117 env.close();
1118 MPTTester mptt{env, issuer, kMptInitNoFund};
1119 mptt.create({.flags = allFlags});
1120 id = mptt.issuanceID();
1121 env.close();
1122 return true;
1123 };
1124 STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
1125
1126 for (auto const flag : std::views::values(getMPTokenIssuanceFlags()))
1127 {
1128 if (flag == lsfMPTLocked)
1129 continue;
1130 Precheck const clearFlag = [&, flag](
1131 Account const&, Account const&, ApplyContext& ac) {
1132 auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(id));
1133 if (!sleIssuance)
1134 return false;
1135 sleIssuance->setFieldU32(sfFlags, sleIssuance->getFlags() & ~flag);
1136 ac.view().update(sleIssuance);
1137 return true;
1138 };
1139
1141 makeEnv(all_ - fixCleanup3_5_0),
1142 {},
1143 clearFlag,
1144 XRPAmount{},
1145 tx,
1147 setup);
1149 makeEnv(all_),
1150 {{"immutable MPTokenIssuance flag cleared"}},
1151 clearFlag,
1152 XRPAmount{},
1153 tx,
1155 setup);
1156 }
1157 }
1158
1159 // A vault pseudo-account's MPToken cannot be deleted by anything
1160 // other than a VaultDelete transaction. Set up a vault, then have
1161 // an arbitrary tx erase the pseudo's MPToken in precheck.
1162 {
1163 UInt256 vaultKey;
1165 {{"vault pseudo-account holding deleted by a "
1166 "non-VaultDelete transaction"}},
1167 [&](Account const&, Account const&, ApplyContext& ac) {
1168 auto const sleVault = ac.view().peek(keylet::vault(vaultKey));
1169 if (!sleVault)
1170 return false;
1171 auto const sleIssuance =
1172 ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
1173 if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
1174 return false;
1175 auto sleHolding = ac.view().peek(
1176 keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding)));
1177 if (!sleHolding)
1178 return false;
1179 ac.view().erase(sleHolding);
1180 return true;
1181 },
1182 XRPAmount{},
1183 STTx{ttACCOUNT_SET, [](STObject&) {}},
1185 [&](Account const& a1, Account const&, Env& env) {
1186 Account const issuer{"issuer"};
1187 env.fund(XRP(10'000), issuer);
1188 env.close();
1189 MPTTester mptt{env, issuer, kMptInitNoFund};
1190 mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
1191 PrettyAsset const asset = mptt.issuanceID();
1192 mptt.authorize({.account = a1});
1193 env.close();
1194
1195 Vault const vault{env};
1196 auto [tx, keylet] = vault.create({.owner = a1, .asset = asset});
1197 env(tx);
1198 env.close();
1199 vaultKey = keylet.key;
1200 return true;
1201 });
1202 }
1203
1204 // Invalid transfer
1205 std::array<std::pair<TxType, bool>, 3> const invalidTransferTests = {
1206 std::make_pair(ttAMM_WITHDRAW, false),
1207 std::make_pair(ttPAYMENT, false),
1208 std::make_pair(ttPAYMENT, true)};
1209 // The two amendments that gate enforcement, in all four combinations.
1210 FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0};
1211 for (auto const gates :
1212 {gatesEnabled,
1213 gatesEnabled - featureMPTokensV2,
1214 gatesEnabled - fixCleanup3_4_0,
1215 FeatureBitset{}})
1216 {
1217 for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests)
1218 {
1219 for (auto const flag :
1220 {static_cast<std::uint32_t>(lsfMPTLocked),
1221 ~lsfMPTCanTransfer,
1222 ~lsfMPTCanTrade,
1223 0u})
1224 {
1225 MPTID id{};
1226 // Issuance flags cannot be cleared after creation (and
1227 // ValidMPTIssuance now rejects it), so the CanTransfer /
1228 // CanTrade rows create the issuance without the bit
1229 // instead of stripping it in precheck.
1230 std::uint32_t const createFlags =
1231 (flag == 0u || flag == lsfMPTLocked) ? kMptDexFlags : (kMptDexFlags & flag);
1232 auto const isSuccess = !gates.any() || flag == 0 ||
1233 (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) ||
1234 (tx == ttAMM_WITHDRAW &&
1235 (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer));
1236 std::pair<TER, TER> const error = isSuccess
1240 {{isSuccess ? "" : "invalid MPToken transfer between holders"}},
1241 [&](Account const& a1, Account const& a2, ApplyContext& ac) {
1242 auto update = [&](AccountID const& a, std::uint64_t v) {
1243 auto sle = ac.view().peek(keylet::mptoken(id, a));
1244 if (!sle)
1245 return false;
1246 sle->at(sfMPTAmount) = v;
1247 ac.view().update(sle);
1248 return true;
1249 };
1250 auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id));
1251 if (!issuanceSle)
1252 return false;
1253 if (flag == lsfMPTLocked)
1254 {
1255 issuanceSle->at(sfFlags) = issuanceSle->at(sfFlags) | lsfMPTLocked;
1256 }
1257 issuanceSle->at(sfOutstandingAmount) = 200;
1258 ac.view().update(issuanceSle);
1259 return update(a1, 101) && update(a2, 99);
1260 },
1261 XRPAmount{},
1262 STTx{
1263 tx,
1264 [&](STObject& tx) {
1265 if (crossCurrencyPayment)
1266 {
1267 tx.setFieldAmount(
1268 sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id}));
1269 }
1270 }},
1271 {error.first, error.second},
1272 [&](Account const& a1, Account const& a2, Env& env) {
1273 Account const gw("gw");
1274 env.fund(XRP(1'000), gw);
1275 MPTTester const usd(
1276 {.env = env,
1277 .issuer = gw,
1278 .holders = {a1, a2},
1279 .pay = 100,
1280 .flags = createFlags});
1281 id = usd.issuanceID();
1282 // Either gate enforces, so both must be off to stay
1283 // advisory. Disable after setting up the MPT; the
1284 // next env.close() is what makes it take effect.
1285 if (!gates[featureMPTokensV2])
1286 env.disableFeature(featureMPTokensV2);
1287 if (!gates[fixCleanup3_4_0])
1288 env.disableFeature(fixCleanup3_4_0);
1289 return true;
1290 });
1291 }
1292 }
1293 }
1294
1295 // An orphan has a zero balance, so only deletion is legitimate (see
1296 // "Skipping Deleted MPTs" in testConfidentialMPTTransfer).
1297 {
1298 MPTID orphanID;
1299 auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) {
1300 MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
1301 mpt.create({.flags = tfMPTCanTransfer});
1302 orphanID = mpt.issuanceID();
1303 // A2 is authorized but never paid, so its balance is zero and
1304 // the issuance can be destroyed while its MPToken lives on.
1305 mpt.authorize({.account = a2});
1306 mpt.destroy();
1307 return true;
1308 };
1309 // ValidMPTBalanceChanges also reports this, so assert on the
1310 // orphan message, which only the missing-issuance branch produces.
1312 {{"orphaned MPToken balance changed"}},
1313 [&](Account const&, Account const& a2, ApplyContext& ac) {
1314 auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
1315 if (!sleTok || (*sleTok)[sfMPTAmount] != 0)
1316 return false;
1317 (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10;
1318 ac.view().update(sleTok);
1319 return true;
1320 },
1321 XRPAmount{},
1322 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1324 setupOrphan);
1325 // Negative control: erasing the orphan is how it gets cleaned up.
1327 {},
1328 [&](Account const&, Account const& a2, ApplyContext& ac) {
1329 auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
1330 if (!sleTok)
1331 return false;
1332 ac.view().erase(sleTok);
1333 return true;
1334 },
1335 XRPAmount{},
1336 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1338 setupOrphan);
1339 // The same erase on a failure. The orphan branch continues, so only
1340 // the pre-loop deletion check can report this one.
1342 {{"MPToken deleted on failure"}},
1343 [&](Account const&, Account const& a2, ApplyContext& ac) {
1344 auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id()));
1345 if (!sleTok)
1346 return false;
1347 ac.view().erase(sleTok);
1348 return true;
1349 },
1350 XRPAmount{},
1351 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1353 setupOrphan,
1356 tecEXPIRED);
1357 }
1358
1359 // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends
1360 // through sfReferenceHolding to test the vault's underlying asset for
1361 // each changed holder.
1362 {
1363 Account const gw{"gw"};
1364 MPTID shareID{};
1365
1366 // Vault setup: a1 and a2 both deposit IOU and hold vault shares.
1367 auto const setupVault = [&](Account const& a1,
1368 Account const& a2,
1370 env.fund(XRP(1'000), gw);
1371 env.trust(gw["IOU"](10'000), a1);
1372 env.trust(gw["IOU"](10'000), a2);
1373 env.close();
1374 env(pay(gw, a1, gw["IOU"](500)));
1375 env(pay(gw, a2, gw["IOU"](500)));
1376 env.close();
1377
1378 Vault const vault{env};
1379 auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]});
1380 env(createTx);
1381 env.close();
1382 env(vault.deposit(
1383 {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
1384 env(vault.deposit(
1385 {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)}));
1386 env.close();
1387
1388 return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)};
1389 };
1390
1391 // Simulate a vault-share transfer: a1 sends 10 shares to a2.
1392 auto const precheck =
1393 [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool {
1394 auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id()));
1395 auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id()));
1396 if (!sle1 || !sle2)
1397 return false;
1398 (*sle1)[sfMPTAmount] -= 10;
1399 (*sle2)[sfMPTAmount] += 10;
1400 ac.view().update(sle1);
1401 ac.view().update(sle2);
1402 return true;
1403 };
1404
1405 // Case: vault pseudo-account's IOU trustline is frozen.
1406 {
1407 auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
1408 auto [sid, vid] = setupVault(a1, a2, env);
1409 shareID = sid;
1410 env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze));
1411 env.close();
1412 return true;
1413 };
1414
1416 Env{*this, all_},
1417 {{"invalid MPToken transfer between holders"}},
1418 precheck,
1419 XRPAmount{},
1420 STTx{ttPAYMENT, [](STObject&) {}},
1422 preclose);
1423 }
1424
1425 // Case: receiver's (a2's) IOU trustline is frozen.
1426 {
1427 auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool {
1428 auto [sid, vid] = setupVault(a1, a2, env);
1429 shareID = sid;
1430 env(trust(gw, gw["IOU"](0), a2, tfSetFreeze));
1431 env.close();
1432 return true;
1433 };
1434
1436 Env{*this, all_},
1437 {{"invalid MPToken transfer between holders"}},
1438 precheck,
1439 XRPAmount{},
1440 STTx{ttPAYMENT, [](STObject&) {}},
1442 preclose);
1443 }
1444 }
1445 }
1446
1447 void
1449 {
1450 using namespace test::jtx;
1451 testcase << "ValidConfidentialMPToken";
1452
1453 MPTID mptID;
1454
1455 // Generate an MPT with privacy, issue 100 tokens to A2.
1456 // Perform a confidential conversion to populate encrypted state.
1457 auto const precloseConfidential =
1458 [&mptID](Account const& a1, Account const& a2, Env& env) -> bool {
1459 MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
1460 mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
1461 mptID = mpt.issuanceID();
1462
1463 mpt.authorize({.account = a2});
1464 mpt.pay(a1, a2, 100);
1465
1466 mpt.generateKeyPair(a1);
1467 mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
1468
1469 mpt.generateKeyPair(a2);
1470 mpt.convert({
1471 .account = a2,
1472 .amt = 100,
1473 .holderPubKey = mpt.getPubKey(a2),
1474 });
1475 return true;
1476 };
1477
1478 // badDelete
1480 {"MPToken deleted with encrypted fields while COA > 0"},
1481 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1482 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1483 if (!sleToken)
1484 return false;
1485 // Force an erase of the object while the COA remains 100
1486 ac.view().erase(sleToken);
1487 return true;
1488 },
1489 XRPAmount{},
1490 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1492 precloseConfidential);
1493
1494 // badConsistency
1496 {"MPToken encrypted field existence inconsistency"},
1497 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1498 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1499 if (!sleToken)
1500 return false;
1501 // Remove one of the required encrypted fields to create a mismatch
1502 sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
1503 ac.view().update(sleToken);
1504 return true;
1505 },
1506 XRPAmount{},
1507 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1509 precloseConfidential);
1510
1512 {"MPToken encrypted field existence inconsistency"},
1513 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1514 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1515 if (!sleToken)
1516 return false;
1517 sleToken->makeFieldAbsent(sfIssuerEncryptedBalance);
1518 sleToken->makeFieldAbsent(sfConfidentialBalanceInbox);
1519 sleToken->makeFieldAbsent(sfConfidentialBalanceSpending);
1520 sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00});
1521 ac.view().update(sleToken);
1522 return true;
1523 },
1524 XRPAmount{},
1525 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1527 precloseConfidential);
1528
1529 // requiresPrivacyFlag
1530 auto const precloseNoPrivacy = [&mptID](
1531 Account const& a1, Account const& a2, Env& env) -> bool {
1532 MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
1533 // completely omitted the tfMPTCanHoldConfidentialBalance flag here.
1534 mpt.create({.flags = tfMPTCanTransfer});
1535 mptID = mpt.issuanceID();
1536 mpt.authorize({.account = a2});
1537 mpt.pay(a1, a2, 100);
1538 return true;
1539 };
1540
1542 {"MPToken has encrypted fields but Issuance does not have "
1543 "lsfMPTCanHoldConfidentialBalance "
1544 "set"},
1545 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1546 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1547 if (!sleToken)
1548 return false;
1549 // Inject all three encrypted fields consistently (inbox+spending+issuer must be
1550 // in sync or badConsistency fires first and masks requiresPrivacyFlag).
1551 sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00});
1552 sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00});
1553 sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00});
1554 ac.view().update(sleToken);
1555 return true;
1556 },
1557 XRPAmount{},
1558 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1560 precloseNoPrivacy);
1561
1562 // badCOA
1564 {"Confidential outstanding amount exceeds total outstanding amount"},
1565 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1566 auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
1567 if (!sleIssuance)
1568 return false;
1569 // Total outstanding is natively 100; bloat the COA over 100
1570 sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200);
1571 ac.view().update(sleIssuance);
1572 return true;
1573 },
1574 XRPAmount{},
1575 STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}},
1577 precloseConfidential);
1578
1579 // Conservation Violation
1581 {"Token conservation violation for MPT"},
1582 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1583 auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
1584 if (!sleIssuance)
1585 return false;
1586
1587 sleIssuance->setFieldU64(
1588 sfConfidentialOutstandingAmount,
1589 sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10);
1590 ac.view().update(sleIssuance);
1591
1592 return true;
1593 },
1594 XRPAmount{},
1595 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1597 precloseConfidential);
1598
1599 // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0)
1601 {"Invariant failed: OutstandingAmount changed "
1602 "by confidential transaction that should not "
1603 "modify it for MPT"},
1604 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1605 auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID));
1606 if (!sleIssuance)
1607 return false;
1608 sleIssuance->setFieldU64(
1609 sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1);
1610 ac.view().update(sleIssuance);
1611 return true;
1612 },
1613 XRPAmount{},
1614 STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
1616 precloseConfidential);
1617
1618 // Send/MergeInbox and zero-COA-delta confidential transactions must not
1619 // change public holder MPTAmount.
1621 {"Invariant failed: MPTAmount changed by confidential "
1622 "transaction that should not modify this field."},
1623 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1624 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1625 if (!sleToken)
1626 return false;
1627 sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1);
1628 ac.view().update(sleToken);
1629 return true;
1630 },
1631 XRPAmount{},
1632 STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}},
1633 // Second pass is tef: the bumped MPTAmount also trips
1634 // ValidMPTTransfer's on-failure check, which escalates the tec.
1636 precloseConfidential);
1637
1638 // badVersion
1640 {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending "
1641 "changed"},
1642 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1643 Blob const kChangedConfidentialSpending = {0xBA, 0xDD};
1644 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1645 if (!sleToken)
1646 return false;
1647 sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending);
1648
1649 // DO NOT update sfConfidentialBalanceVersion
1650 ac.view().update(sleToken);
1651 return true;
1652 },
1653 XRPAmount{},
1654 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1656 precloseConfidential);
1657
1658 // Skipping Deleted MPTs (Issuance deleted)
1659 auto const precloseOrphan = [&mptID](
1660 Account const& a1, Account const& a2, Env& env) -> bool {
1661 MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
1662 mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance});
1663 mptID = mpt.issuanceID();
1664 mpt.authorize({.account = a2});
1665
1666 // Generate privacy keys and convert 0 amount so Bob has the encrypted fields
1667 mpt.generateKeyPair(a1);
1668 mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)});
1669 mpt.generateKeyPair(a2);
1670 mpt.convert({
1671 .account = a2,
1672 .amt = 0,
1673 .holderPubKey = mpt.getPubKey(a2),
1674 });
1675
1676 // Immediately destroy the issuance. A2's empty, encrypted token object lives on.
1677 mpt.destroy();
1678 return true;
1679 };
1680
1682 {},
1683 [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) {
1684 auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1685 if (!sleToken)
1686 return false;
1687 // Safely able to erase the deleted token.
1688 ac.view().erase(sleToken);
1689 return true;
1690 },
1691 XRPAmount{},
1692 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1694 precloseOrphan);
1695 }
1696
1697 // deletedHoldings_ in ValidMPTIssuance captures every erased MPToken
1698 // per-holder, so finalize()'s fixCleanup3_5_0 balance check must reject
1699 // a funded MPToken erased alongside an empty sibling regardless of the
1700 // order the two are visited in.
1701 void
1703 {
1704 using namespace test::jtx;
1705 testcase << "MPToken deleted with non-zero balance, two holders";
1706
1707 MPTID mptID;
1708 Account const carol{"carol"};
1709
1710 // Single-holder setup used by the baseline and the amendment-gate
1711 // cases.
1712 auto const setupSingle = [&](Account const& a1, Account const& a2, Env& env) -> bool {
1713 MPTTester mpt(env, a1, {.holders = {a2}, .fund = false});
1714 mpt.create({.flags = tfMPTCanTransfer});
1715 mptID = mpt.issuanceID();
1716 mpt.authorize({.account = a2});
1717 mpt.pay(a1, a2, 100);
1718 return true;
1719 };
1720
1721 Precheck const eraseSingle = [&](Account const&, Account const& a2, ApplyContext& ac) {
1722 auto sleA2 = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1723 if (!sleA2)
1724 return false;
1725 ac.view().erase(sleA2);
1726 return true;
1727 };
1728
1729 Precheck const eraseBoth = [&](Account const&, Account const& a2, ApplyContext& ac) {
1730 auto sleA2 = ac.view().peek(keylet::mptoken(mptID, a2.id()));
1731 auto sleCarol = ac.view().peek(keylet::mptoken(mptID, carol.id()));
1732 if (!sleA2 || !sleCarol)
1733 return false;
1734 ac.view().erase(sleA2);
1735 ac.view().erase(sleCarol);
1736 return true;
1737 };
1738
1739 // Cases below expecting `tecINVARIANT_FAILED` use ttACCOUNT_SET; the
1740 // success case (fixCleanup3_5_0 disabled) uses ttMPTOKEN_AUTHORIZE
1741 // to sidestep unrelated invariants that would otherwise mask the
1742 // tesSUCCESS signal.
1743
1744 // Baseline: a single funded MPToken erased on its own.
1746 {{"MPToken deleted with non-zero balance"}},
1747 eraseSingle,
1748 XRPAmount{},
1749 STTx{ttACCOUNT_SET, [](STObject&) {}},
1751 setupSingle);
1752
1753 // Two-holder erase: whichever key sorts last is visited last, so
1754 // funding each holder in turn guarantees one run where the funded
1755 // MPToken is visited first and an empty sibling follows it.
1756 for (bool const fundCarol : {false, true})
1757 {
1758 auto const setupTwo = [&, fundCarol](
1759 Account const& a1, Account const& a2, Env& env) -> bool {
1760 env.fund(XRP(1'000), carol);
1761 MPTTester mpt(env, a1, {.holders = {a2, carol}, .fund = false});
1762 mpt.create({.flags = tfMPTCanTransfer});
1763 mptID = mpt.issuanceID();
1764 mpt.authorize({.account = a2});
1765 mpt.authorize({.account = carol});
1766 mpt.pay(a1, fundCarol ? carol : a2, 100);
1767 return true;
1768 };
1769
1771 {{"MPToken deleted with non-zero balance"}},
1772 eraseBoth,
1773 XRPAmount{},
1774 STTx{ttACCOUNT_SET, [](STObject&) {}},
1776 setupTwo);
1777 }
1778
1779 // fixCleanup3_5_0 enabled, fixCleanup3_2_0 disabled: the two
1780 // amendments are independent, so this is the only configuration in
1781 // which the `|| isFeatureEnabled(fixCleanup3_5_0)` half of the
1782 // deletedHoldings_ capture gate in ValidMPTIssuance::visitEntry is
1783 // load-bearing.
1785 makeEnv(all_ - fixCleanup3_2_0),
1786 {{"MPToken deleted with non-zero balance"}},
1787 eraseSingle,
1788 XRPAmount{},
1789 STTx{ttACCOUNT_SET, [](STObject&) {}},
1791 setupSingle);
1792
1793 // fixCleanup3_5_0 disabled: erasing a funded MPToken must not trip
1794 // the new check. This pins the fixCleanup3_5_0 gate in finalize().
1796 makeEnv(all_ - fixCleanup3_5_0),
1797 {},
1798 eraseSingle,
1799 XRPAmount{},
1800 STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}},
1802 setupSingle);
1803 }
1804
1805public:
1806 void
1807 run() override
1808 {
1810 testMPT();
1812 }
1813};
1814
1815BEAST_DEFINE_TESTSUITE(InvariantsMPT, app, xrpl);
1816
1817} // namespace xrpl::test
LogOs< char > log
Logging output stream.
Definition suite.h:150
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
State information when applying a tx.
ApplyView & view()
virtual SLE::pointer peek(Keylet const &k)=0
Prepare to modify the SLE associated with key.
virtual void insert(SLE::Ref sle)=0
Insert a new state SLE.
virtual void erase(SLE::Ref sle)=0
Remove a peeked SLE.
virtual void update(SLE::Ref sle)=0
Indicate changes to a peeked SLE.
A currency issued by an account.
Definition Issue.h:18
constexpr MPTID const & getMptID() const
Definition MPTIssue.h:43
Identifies fields.
Definition SField.h:132
void negate()
Definition STAmount.h:586
static constexpr SeqProxy rawSequence(std::uint32_t v)
Factory function to return a sequence-based SeqProxy.
Definition SeqProxy.h:62
std::function< bool(test::jtx::Account const &a, test::jtx::Account const &b, ApplyContext &ac)> Precheck
test::jtx::Env makeEnv(FeatureBitset features)
void doInvariantCheck(std::vector< std::string > const &expectLogs, Precheck const &precheck, XRPAmount fee=XRPAmount{}, STTx tx=STTx{ttACCOUNT_SET, [](STObject &) {}}, std::initializer_list< TER > ters={tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const &preclose={}, TxAccount setTxAccount=TxAccount::None, std::source_location const &loc=std::source_location::current(), TER initialResult=tesSUCCESS)
Run a specific test case to put the ledger into a state that will be detected by an invariant.
std::function< bool(test::jtx::Account const &a, test::jtx::Account const &b, test::jtx::Env &env)> Preclose
void run() override
Runs the suite.
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
AccountID id() const
Returns the Account ID.
A transaction testing environment.
Definition Env.h:161
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
void trust(STAmount const &amount, Account const &account)
Establish trust lines.
Definition Env.cpp:354
Test helper for creating, mutating, and asserting MPT and confidential MPT ledger state.
Definition mpt.h:512
MPTID const & issuanceID() const
Definition mpt.h:768
void create(MPTCreate const &arg=MPTCreate{}, std::source_location const &loc=std::source_location::current())
Definition mpt.cpp:343
void authorize(MPTAuthorize const &arg=MPTAuthorize{}, std::source_location const &loc=std::source_location::current())
Definition mpt.cpp:455
T current(T... args)
T make_pair(T... args)
T make_shared(T... args)
Keylet computation functions.
Definition Indexes.h:40
Keylet check(AccountID const &id, SeqProxy const &seq) noexcept
A Check.
Definition Indexes.cpp:360
Keylet unchecked(UInt256 const &key) noexcept
Any ledger entry.
Definition Indexes.cpp:397
Keylet vault(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:591
Keylet mptoken(MPTID const &issuanceID, AccountID const &holder) noexcept
Definition Indexes.cpp:573
Keylet account(AccountID const &id) noexcept
AccountID root.
Definition Indexes.cpp:220
Keylet mptokenIssuance(MPTID const &issuanceID) noexcept
Definition Indexes.cpp:567
Keylet trustLine(AccountID const &id0, AccountID const &id1, Currency const &currency) noexcept
The index of a trust line for a given currency.
Definition Indexes.cpp:275
auto const kMptDexFlags
Definition mpt.h:47
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
FeatureBitset testableAmendments()
Definition Env.h:92
json::Value trust(Account const &account, STAmount const &amount, std::uint32_t flags)
Modify a trust line.
Definition trust.cpp:18
static MPTInit const kMptInitNoFund
Definition mpt.h:201
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
STTx createTx(bool disabling, LedgerIndex seq, PublicKey const &txKey)
Create ttUNL_MODIFY Tx.
constexpr XRPAmount
Convert XRP to drops (integral types).
Definition TxTest.h:54
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
@ tefINVARIANT_FAILED
Definition TER.h:178
BaseUInt< 256 > UInt256
Definition base_uint.h:580
BaseUInt< 192 > MPTID
MPTID is a 192-bit value representing MPT Issuance ID, which is a concatenation of a 32-bit sequence ...
Definition UintTypes.h:54
MPTID makeMptID(std::uint32_t const sequence, AccountID const &account)
Definition Indexes.cpp:206
BaseUInt< 160, detail::AccountIDTag > AccountID
A 160-bit unsigned that uniquely identifies an account.
Definition AccountID.h:34
TERSubset< CanCvtToTER > TER
Definition TER.h:654
@ tecINCOMPLETE
Definition TER.h:343
@ tecINVARIANT_FAILED
Definition TER.h:321
@ tecEXPIRED
Definition TER.h:322
@ tecKILLED
Definition TER.h:324
std::vector< unsigned char > Blob
Storage for linear binary data.
Definition Blob.h:11
constexpr std::uint64_t kMaxMpTokenAmount
The maximum amount of MPTokenIssuance.
Definition Protocol.h:297
@ tesSUCCESS
Definition TER.h:250